mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
main
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4679327ddc |
Sentinel reads the errors /v1/event lands, and takes none itself
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
The event plane folded every occurrence into event.fact, and the Sentry module still read and wrote event.error. Its reads now select the error signal of event.fact, mapping issue, env, origin and the severity band. Errors enter through /v1/event only. The Sentry envelope and store routes, DSN keys and their rotation leave the module; the consumer of event.error files each fact through Ingest(org, product), which keeps the issue lifecycle and returns the issues a batch created. |
||
|
|
cae99adb44 |
The relay reads the operation zip served
An op learns its address from zip.OpOf and zip.AddressOf, so the claim table, the five declaring verbs and the address bookkeeping are gone. A host that declares one of these addresses itself now gets a Build conflict from zip. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
2965c80547 |
An address is measured from a root this package owns
The five claim verbs need the address an operation will answer at, which is a root joined with a path. They used to ask the target for its prefix through a struct written to answer with one — and that struct existed for a second reason that has since gone: an operation with no declared id took a shape-derived one qualified by the prefix of the occurrence it answered under, so a plain group would have renamed every undeclared id. All 319 operations state their own id now, so a declared id survives composition verbatim and the group is just a group. The roots are this package's own constants, so each group carries the one it was made with and nothing can disagree about the address. The escape hatches name their whole address and register through Raw, which says which method each one answers. The test that measured the gap reached zero and said so; it now holds the property from the other side, that no operation goes undeclared. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
f4b78d6371 |
docs: name the thing instead of calling it a door
One word stood for several: the MCP server, one address that accepts requests, the single way in, and the boundary a credential is validated at. Doc comments here are lifted into OpenAPI descriptions and republished on docs.hanzo.ai, so the ambiguity was reaching customers. Each site now names its own sense — endpoint, entry point, the MCP server, the edge, address — or takes the word the surrounding code already uses. Comments and prose strings only. Identifiers keep their names, including the doc comments that must open with the identifier they document. go build ./... passes. |
||
|
|
c10b49bbe8 |
feat(o11y): nothing answers outside /v1/o11y — sentinel folds, /ws merges
Two addresses were still spelled at a root that is not this capability's, and HIP-0139 §3 gives every route one home: /v1/<name>. Both close by fold, and neither leaves an alias behind. /v1/sentinel/* -> /v1/o11y/sentinel/*. The error-tracking face is twelve concrete paths the runtime registers literally, plus the declaration table's group over the same root, so one constant and the registrations under it move together. It is one capability's second face, not a second capability: same store, same runtime, same tenant rule. The ingest door is unaffected — a minted DSN names /v1/event and always did. /ws/query_progress merges onto /v1/o11y/query_progress. It was never a second read: one handler answered both mount points, upgrading when the caller asked and long-polling when it did not. The Upgrade is a property of the request, not of the address, so two paths bought a second spelling and nothing else. /ws was also the last address this service answered outside /v1, and the console's api-prefix list drops it with the route. The hatch census goes eleven to ten and the surface 367 to 366 — the numbers are tests here, not prose, so they move in the same commit as the route. |
||
|
|
c42aa37d71 |
feat(sentinel): the ingest door is /v1/event, off the product face
One wire had two public addresses. The Sentry-wire hatch was registered under
sentinelRoot, so /v1/sentinel/{project}/envelope|store answered alongside the
real door — and a face is not a door. A face is read by a signed-in person and
answers about what is already stored; a door is knocked on by a keyed beacon
that holds no session and carries something in. Different callers, different
credentials, so they no longer share a root.
eventRoot ("/v1/event") is that door, and it is the SAME string mintDSN spells,
so the address a client is told about and the address this service opens are one
value. IngestWire matches on it, and still by METHOD + PREFIX + SUFFIX rather
than a bare prefix, so nothing else on either root is reachable through the
exemption — the faces stay gated, and so does /v1/event itself, the product
event door beside this one.
The /api/ segment stays where it is. A stock SDK appends its own fixed
/api/<project>/envelope/ to whatever DSN path it is given, so that spelling is
the protocol's and not ours to rename.
|
||
|
|
2e23268ee3 |
feat(sentinel): /v1/sentry becomes /v1/sentinel; the DSN names /v1/event
/v1/sentry named our product after somebody else's. The face is /v1/sentinel now — issues, projects, stats, logs, traces, discover: the fifteen addresses a signed-in person reads. One const carries it (relay.go's sentinelRoot), which is why the whole surface moved from one line; the groups in telemetry.go and sentryerrors.go derive from it and nothing restates a path. The WIRE is untouched and stays untouched. sentry_key, X-Sentry-Auth and application/x-sentry-envelope are a third party's protocol that we RECEIVE, not a name we chose, so renaming them would break every SDK in the field and buy nothing. Sentinel is what we call the product; the envelope is what arrives. mintDSN now addresses /v1/event. A DSN is the INGEST door and the face is not — @hanzo/event 0.3.29 derives its ingest URL from the DSN's own path, so this line decides where every reporting client posts. It said /v1/sentry, which after the rename would have pointed clients at a read surface. Ingest and face are different nouns and no longer share a root. zipdoc_gen.go's fifteen route keys were moved by hand, and that is not a licence to hand-edit generated files: `go generate -run zipdoc ./...` FAILS on this tree before any change of mine — claim.go:146's generic opGet wrapper makes the path non-constant, so zipdoc refuses the root package. Verified against a stashed pristine tree. The keys are literal strings and this is what a working generator would emit; the generator is the real repair and is owed. Every count the repo pins still holds: 353 typed ops, 353 published operations, 353 MCP tools, 367 routes. 155 packages green. BREAKING CHANGE: the error-tracking read surface moves from /v1/sentry to /v1/sentinel, and minted DSNs address /v1/event. |
||
|
|
599a26cedf |
the runtime answers by name, so the declaration is the only registration
o11y's surface was stated twice: once as the implementation (367 routes through
routing.Router) and once as the module's own declaration of it (353 typed ops +
11 hatches + 3 probes, the same 367 addresses with an input, an output and their
prose). The declaration had to REACH the implementation, and the only way it
could was to speak HTTP to the WHOLE service — build a request, hand it to one
http.Handler that was the entire router, and let that router match the path a
second time to find the handler the declaration had already named. An in-process
round trip, across a net/http<->fasthttp bridge, through a recorder, to answer a
question the caller had the answer to.
A registration is a map from address to handler. routing.Table.Handler reads it
as one and o11y.SetRuntime hands the whole table over, so the second match is
gone and with it the failure class the reachability census exists for: a request
cannot lose its route to a router that is never consulted.
Two things could not be said before and now can:
- "this address is not implemented." A declared address the runtime does not
serve came back as the runtime's own 404, indistinguishable from a caller's
typo. It is a nil at the seam now. The two sides HAD drifted: three addresses
the declaration names {traceId} were registered {traceID}, invisible because
a router matches by POSITION and a segment's name never reaches it.
- "the runtime is right here." The standalone server IS the runtime and could
not install itself as that one handler without an op relaying into the router
already serving it, so it installed nothing — and every op on its /mcp
surface and its by-name call plane answered 503 in the binary holding all 367
handlers. It installs its own table now.
The address is stated ONCE, at the registration, and rides the call: claim.go's
verbs wrap every op in addressed, which puts the address into the context, and
relay reads it there. relay takes no method and no path — which door was knocked
on is not something an op's body decides. That retires 706 spellings of 367
addresses; the 119 hand-built concatenations are rendered by zip.Address from the
same input zip bound the segments into, so the round trip is exact by
construction rather than by eye.
Reached BY NAME there is no router in front of the handler, so the two chain
members that need the route — Audit keys its record on the template, Resource
resolves the declared resources — would read an empty one, silently. Table.Handler
hands back the chain carrying the template it was registered at, written through
coretypes.SetRoute: the same representation bind writes for a matched request,
one read on the other side. TestByNameCarriesTheSameRouteAsMatching drives both
doors against one registration and compares them to each other.
Addresses did not move: the census — 367 fiber routes, 367 declared, 353
operation ids — is byte-identical to the parent. 155 packages ok, 0 fail, the
same set by name as the parent.
wantQueryServiceRoutes closes the equality argument the sibling address test
makes. It rested on four facts and 134 was a sentence, so a route deleted in
pkg/query-service/app left both address tests green while the declaration went on
naming an address nothing serves — which costs more now that it is a 503 rather
than a 404.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
9119c4b747 |
A host may take an address, and now it can say so
Every route in this table is named. That is what made the table honest, and it is also what turned a host's overlapping route from a silent shadow into a refusal to compose: zip validates that one address is declared once, so a host declaring GET /v1/o11y/metrics alongside this table does not win, it panics. hanzoai/cloud declares three of these addresses natively — GET /v1/o11y/logs, GET /v1/o11y/metrics, POST /v1/o11y/query_range — and used to win them by registering first, back when a wildcard answered everything else. The wildcard is gone, so ordering decides nothing and the overlap is a boot failure. In-order shadowing was never a decision, only an artefact of registration order, and the two handlers it hid could drift apart forever without anyone being told. Claimed makes the host NAME what it takes, which is the same fact written down where it can be read. WHY A VERB PER DECLARATION, NOT A FILTER ON THE TARGET. The check needs the ADDRESS, and an address is the target's prefix plus the path. zip resolves an OpTarget once, without a path — OpScope carries no route — so a target cannot refuse one address and accept another. The declaration can, so the five verbs each gained the check and all 353 typed ops plus the eleven hatches go through them. Gating only the three addresses that collide today would have been a seam that was true where someone remembered it, which is the shape of defect this package already shipped once. A claim names a method AND a path, spelled the way zip spells an address in its own diagnostics — "GET /v1/o11y/metrics" — so the string that names the conflict is the string that resolves it. A claim on an address this table does not declare is inert rather than an error: the host is describing what IT serves, and a table that stopped declaring an address must not become a boot failure in every host that had claimed it. Nothing about the runtime changes. A claimed operation is still there and still reachable on the runtime's own router; this table stops PUBLISHING it, so the host's handler is the one answer at that address in the host's process. Tested: an unclaimed Mount declares everything (the control, so the absences are attributable to the claim); a claimed address is not declared while its siblings, its other methods and the rest of the table are; a claimed hatch goes too, since the eleven took a different verb; an unknown claim is inert; and a claim does not leak into the next Mount in the same process. The full root suite, including the 353-op census, is green. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
bf6e17e0af |
chore(zip): v1.23.0 — Use is the one composition verb
Four changes zip v1.23 forced. Only the first is a compile error; the other
three are silent, and two of them would have shipped a broken service.
1. Prepare() -> Build() error. publish already returned an error, so the verdict
propagates: a composition this server cannot serve now fails at startup
naming both claimants, where before it could only be found by the routes
going missing.
2. routing.Router read its prefix back off zip (OpScope().Prefix). A group is
now an App INCLUDED at a prefix rather than a router carrying one, so
OpScope().Prefix is empty for every group — and reading it STILL COMPILES.
The Table would have silently recorded every nested route at its leaf path.
The prefix is composed at Group now, by the same join Handle records with.
3. THE OPERATION IDS. zip qualifies an op's id by the prefix of the occurrence
it answers under, so every id declared under app.Group(o11yRoot) became
"v1.o11y.CreateRole". That is 353 renamed MCP tools, OpenAPI operationIds,
CLI commands and generated SDK methods — in a patch release, with no opt-out
(an explicit WithOperationID is qualified too). The rule is right for a
composition; o11yRoot is not one. It is this service's own address, and an
occurrence at the ROOT prefix is unqualified. So the ops register on the app
with OpScope.Prefix doing what it documents — prepend to the op's path. Same
method, same full path, same middleware, and the id the declaration wrote.
One type (relay.go `under`), 18 call sites, no route literal touched. It is
also what mount.go's PATH UNTOUCHED already claimed and what mountHealth
already did by hand.
4. ONE ADDRESS, ONE DEFINITION — and the standalone server declared 367 twice.
The service's own handlers SERVE those addresses and the published table
DESCRIBES them; zip has no way to spell that difference ("the route and the
op are the same fact recorded once"), so Build refused the program and the
community server could not boot. The two facts now live on two apps: the
declaration builds on an app of its own and only its CONTROL routes — the
document, /docs, /mcp, the call plane, the plugin declaration — are served
from the listener. zip keeps control routes off the walk, and none of the
five is one of this service's 367, so nothing can shadow or be shadowed.
This also makes ORDER IS THE CONTRACT structural instead of positional: the
declaration is not on the listener at all, so there is no order to get wrong,
and livetail, the long-poll and the chunked export still stream unbuffered.
TestPublishServesTheDocument now reads the tool count from a tools/list over
the served door rather than len(app.MCPTools()) — an in-process accessor on the
app the test just built is the shape of proof that file exists to reject.
Measured: go build ./... clean; go vet ./... clean apart from pre-existing
"unreachable code" in the generated parsers; go test ./... 155 ok, 0 failed.
|
||
|
|
2d4b46d8ee |
mount: the wildcard is gone — every one of 367 routes is named
The delegation catch-all made three facts indistinguishable: a route that was
converted, a route that was not, and a route that did not exist. That is not a
theoretical cost — it is exactly how 83 typed ops shipped in files nothing
called, passing a full green suite while every one of those routes fell through
the wildcard.
So: derive the gap precisely, close it, and delete the catch-all.
THE GAP, re-derived. Extract every path the runtime registers (134 in
pkg/query-service/app/routes_*.go, 233 in pkg/apiserver/o11yapiserver/*.go — the
134 matching the count routes.go documents), normalise :params on both sides,
and diff against what Mount installs. 367 runtime routes, 43 unnamed. Not the
four hatch classes the plan expected: eleven were genuine hatches and
THIRTY-TWO were simply never converted.
CONVERTED (32 new typed ops, three new slices):
* traces.go — a trace's spans, the trace field catalog and its tuning
write, waterfall, flamegraph, span aggregations
* tracefunnels.go — funnel CRUD plus twelve analytics reads, six over a saved
funnel and six over one described inline
* spanmappers.go — the ingest-time attribute mapping groups and their mappers
THE ELEVEN HATCHES, each its own route literal with its reason beside it
(mount.go mountHatches): four streams with no single answer to name (livetail,
query_progress long-poll, its websocket, the chunked export), three 303
redirects whose answer is a Location and not a body, and four Sentry-SDK ingest
doors — a foreign protocol we RECEIVE, whose /api/ segment the SDK hardcodes and
which is not ours to rename.
TWO OF THOSE ELEVEN WERE UNREACHABLE. /ws/query_progress and the two
/v1/sentry/<project>/{envelope,store}/ doors sit outside /v1/o11y, so the old
catch-all never saw them: a Sentry SDK pointed at the clean root got a 404 in
the composed binary, and telemetry_test.go passed only because the test
registered the wildcard the deployment did not have. Naming every route is what
surfaced it.
ONE SEAM, TWO ROOTS. Five copies of the relay function (relay, relayAt,
relayO11y, relayAPM, send) differing only in a prefix and whether they took
query parameters, and NINE names for two values — identityPrefix, infraPrefix,
integrationsPrefix, metricsRoot, apmPrefix, accessPrefix, rulesAlertsPrefix and
o11yRoot were all "/v1/o11y". Ops now pass the whole public path and relay
concatenates nothing: 193 call sites, one implementation, -520 lines.
Also: the per-slice *_zipdoc.go files fold back into zipdoc_gen.go as their own
headers instructed; the health probes' fall-through names its destination
instead of relying on a catch-all being registered after it (they would have
started 404ing the moment SetHealth was late — precisely when liveness matters);
O11yLogField/O11yLogFieldsOut/O11yLogFieldSetting become
O11yTelemetryField/O11yFieldCatalogOut/O11yFieldSetting because logs and traces
tune ONE catalog through one runtime type.
PAYOFF, counted off the document rather than the router: 353 operations in the
published OpenAPI spec and 353 MCP tools, from 0 for this surface before the
conversion began. 353 typed + 11 hatches + 3 probes = 367.
RECORDED, NOT FIXED: the runtime renders with jsoniter and zip with the stdlib
encoder, and jsoniter does not implement `,omitzero` — so for the seventeen
fields across pkg/types that use it the runtime writes the zero value where a
typed op omits the key. Pre-existing on every shipped op reaching
TelemetryFieldKey. Pinned by traces_test.go rather than hidden.
gorilla/mux and gin stay: mux is the runtime's own router in 100 files, and gin
is imported only for its form binder by pkg/valuer/valuer.go
(ginbinding.BindUnmarshaler) and pkg/http/binding/query.go
(ginbinding.MapFormWithTag). go mod tidy dropped 687 lines of go.mod/go.sum.
Build and full suite green: 133 packages ok, 0 fail.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
e58df12a5c |
zip: sentry-errors — 18 routes become typed ops (View/Edit gates carried, 4 ingest escape-hatched)
The Sentry product face's projects, issues and one event; the error-tracking
issues; and the legacy exceptions reads — eighteen routes that reached traffic
only through the /v1/sentry and /v1/o11y delegation wildcards, so they were in no
document: no SDK method, no CLI command, no agent tool, no reference page. They
are typed ops now — real In, real Out, the prose lifted from the handler doc
comments into zipdoc (sentryerrors_zipdoc.go) — the only construct that reaches
all of those projections.
THE WIRE DOES NOT MOVE. No op re-implements anything: each hands the call to the
SAME runtime handler the wildcard delegates to (relay for /v1/sentry, relayAt for
/v1/o11y), so identity resolution, the org gate, the ROLE CHECK each route
declared, the audit record, the timeout and the success envelope stay exactly
where they were and run in the order they always did. What is new is the TYPE.
GATES CARRIED EXACTLY, enforced one layer in by the runtime the op relays into;
the gate is not re-stated in the op, each op's comment names it:
- sentry projects: ViewAccess on list/get, EditAccess on create/delete/rotate-key
- sentry issues: ViewAccess on list/get/events, EditAccess on update
- sentry event: ViewAccess
- errortracking issues: ViewAccess on list/get, EditAccess on update
- legacy exceptions (listErrors, countErrors, errorFromErrorID,
errorFromGroupID, nextPrevErrorIDs): ViewAccess, every one
FOUR INGEST ROUTES ARE THE ESCAPE HATCH, not typed here: POST
/v1/sentry/{project}/envelope|store/ and POST /v1/o11y/api/{project}/envelope|store/.
They are OpenAccess (a Sentry SDK presents a DSN key, not a Hanzo session) and
carry an OPAQUE Sentry-envelope body — a foreign wire we receive verbatim, like
/.well-known. A typed relay JSON-encodes its input, which would corrupt that raw
envelope, so they stay on the runtime mux byte-identical and out of the document,
which cannot describe an opaque proxy honestly.
The five telemetry reads of this same face (discover, logs, traces, one trace,
stats) were already typed in telemetry.go; this file is the rest of it.
Schema names are qualified — O11ySentryProject, O11yErrorIssue, O11yErrorWithSpan
and siblings — because these enter a fleet-wide document where one name may have
exactly one shape. The sentry and error-tracking reads answer inside
render.Success's {status, data} envelope, so those Outs carry it; the legacy
exceptions reads answer BARE (the older WriteJSON writes no envelope), so those
Outs are the payload itself — a list, a number, an object.
GATE: go build ./... green; go test ./... green (exit 0). mountSentryErrors is
defined and ready; wiring its one call into mount.go's Mount is the integration
step (shared file, concurrent slices).
Co-authored-by: Hanzo Dev <[email protected]>
|