mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
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]>
214 lines
12 KiB
Go
214 lines
12 KiB
Go
package o11y
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// Mount registers Hanzo o11y's HTTP surface under /v1/o11y per HIP-0106.
|
|
//
|
|
// A ROUTE TABLE IS A VALUE, AND IT TAKES THE ROUTER AND NOTHING ELSE. Mount used
|
|
// to take a second argument — the embedding host's dependency struct,
|
|
// hanzoai/cloud's Deps — and it read exactly ONE field of it, Logger, on exactly
|
|
// one line, to announce itself. That one line braided WHAT the routes are into
|
|
// WHICH host serves them, and the price was paid three levels up:
|
|
//
|
|
// - github.com/hanzoai/o11y required github.com/hanzoai/cloud, which requires
|
|
// github.com/hanzoai/o11y. A module CYCLE, for a log line.
|
|
// - Because the cycle made `go mod download` unresolvable in an image that has
|
|
// no cloud checkout, the community Dockerfile had to build one package
|
|
// instead of the module, and that package — cmd/community — could not import
|
|
// these declarations without dragging the host in. So it did not import them
|
|
// at all, and the whole conversion (353 typed ops, 353 published operations,
|
|
// 353 MCP tools) shipped in a package the running process never linked.
|
|
//
|
|
// The router already carries a logger: the host configured it with its own when
|
|
// it built the app, so app.Logger() is the SAME value Deps.Logger was, reached
|
|
// through the argument that was already here. Nothing was carried by the second
|
|
// argument that the first did not already have.
|
|
//
|
|
// With it gone the declarations stand alone, and BOTH hosts mount this ONE
|
|
// table: the community server's own public router (pkg/query-service/app, which
|
|
// is also the handler the unified binary embeds) and hanzoai/cloud's outer
|
|
// router. One declaration, two hosts, nothing to drift.
|
|
//
|
|
// The o11y runtime (metrics, traces, logs, dashboards, alerts) is heavy:
|
|
// telemetry stores, rule manager, websocket attachments, opamp server. These
|
|
// routes do not re-implement it — each hands its call to the runtime's handler
|
|
// FOR ITS OWN ADDRESS (see [SetRuntime] and relay), and until a runtime is
|
|
// installed they 503 with a clear error rather than pretending.
|
|
//
|
|
// EVERY ROUTE IS NAMED. There is no /v1/o11y/* catch-all any more. The runtime
|
|
// registers 329 method+path pairs; 322 of them are typed ops declared in the
|
|
// slice files below, and the remaining 7 are registered one by one in
|
|
// mountHatches with the reason each cannot be typed written next to it. A
|
|
// catch-all hides the difference between "converted" and "not converted" — the
|
|
// dark-slice defect this package already shipped once was invisible precisely
|
|
// because a wildcard answered for the routes nobody had wired up. A named route
|
|
// per hatch makes the escape hatches countable, and makes any NEW un-typed route
|
|
// a 404 that someone has to come and justify rather than a silent fall-through.
|
|
//
|
|
// PATH UNTOUCHED. Delegation never rewrites r.URL: every route is registered at
|
|
// its full public path, so the route literal IS the contract — one spelling,
|
|
// nothing to translate, nothing to drift.
|
|
// One address, one declaration. Every route is named, so a host that serves one
|
|
// of these addresses itself gets a Build conflict from zip rather than a second
|
|
// handler standing behind the first.
|
|
func Use(app *zip.App) error {
|
|
app.Logger().Info("o11y: mounting routes", "prefix", o11yRoot)
|
|
|
|
// Native probe group, registered ahead of everything else so Fiber's
|
|
// in-order match serves it off the mux tree (see health.go).
|
|
mountHealth(app)
|
|
// The TYPED telemetry ops of the error-tracking face — the five reads that
|
|
// now carry a named input, a named output and their prose into the document
|
|
// instead of hiding behind a wildcard. They answer on the runtime, so the
|
|
// wire is unchanged; see telemetry.go.
|
|
mountTelemetry(app)
|
|
// The TYPED infra ops — hosts, processes, the Kubernetes fleet and the
|
|
// infra_monitoring rollups; forty-four reads that now carry named inputs,
|
|
// named outputs and their prose into the document. They answer on the
|
|
// runtime, so the wire is unchanged; see infra.go.
|
|
mountInfra(app)
|
|
// The TYPED logs ops — the record read, the field catalog and its tuning
|
|
// write, the aggregate read, pipelines and path promotion; live tail stays
|
|
// a hatch because it is a stream. Same construction: they answer on the
|
|
// runtime, so the wire is unchanged; see logs.go.
|
|
mountLogs(app)
|
|
// The TYPED identity ops — who the caller is, my-org, quick filters and
|
|
// preferences. Same construction: named In, named Out, answered on the
|
|
// runtime, wire unchanged; see identity.go. Invites, passwords, reset
|
|
// tokens, member administration, sessions, SSO domains and the /complete/*
|
|
// sign-in callbacks are GONE, not hatched: Hanzo IAM owns credentials.
|
|
mountIdentity(app)
|
|
// The TYPED access-control ops — roles, service accounts, service-account
|
|
// keys and the authorization probe. Same construction: named In, named Out,
|
|
// answered on the runtime, so the wire — including every CheckResources and
|
|
// OpenAccess gate — is unchanged; see access.go.
|
|
mountAccess(app)
|
|
// The TYPED APM ops — the service catalog, the messaging-queue views and
|
|
// the third-party API overview; twenty-one reads that now carry named
|
|
// inputs, named outputs and their prose into the document. Same
|
|
// construction: named In, named Out, answered on the runtime, wire
|
|
// unchanged; see apm.go.
|
|
mountAPM(app)
|
|
// The TYPED dashboard ops — v2 dashboard CRUD, cloning, locking, per-user
|
|
// pinning, the org-shared saved views, public-sharing config and the two
|
|
// anonymous public reads; twenty-two routes that now carry named inputs,
|
|
// named outputs and their prose into the document. Same construction: they
|
|
// answer on the runtime, so the wire is unchanged; see dashboards.go.
|
|
mountDashboards(app)
|
|
// The TYPED LLM-observability ops — the four gen_ai span views
|
|
// (observations, traces, sessions, users), eval scores, human annotations
|
|
// and the LLM pricing rules; fourteen reads and writes that now carry named
|
|
// inputs, named outputs and their prose into the document. Same
|
|
// construction: they answer on the runtime, so the wire is unchanged; see
|
|
// llmobs.go.
|
|
mountLLMObs(app)
|
|
// The TYPED metrics ops — the metrics-explorer reads and the metadata write,
|
|
// and the volume-control (metric reduction) rules; nineteen ops that now
|
|
// carry named inputs, named outputs and their prose into the document. They
|
|
// answer on the runtime, so the wire is unchanged; see metrics.go.
|
|
mountMetrics(app)
|
|
// The TYPED platform ops — instant queries, dashboard variables, product
|
|
// events, usage, the dependency graph, version/health, disks, first-user
|
|
// registration, retention and apdex settings, licenses, global config,
|
|
// feature flags, org stats, filter suggestions, k8s onboarding, metric
|
|
// metadata, span percentiles and query-filter analysis. Same construction:
|
|
// they answer on the runtime through the one shared relay seam, so the wire
|
|
// is unchanged; see platform.go. The three service probes (healthz, readyz,
|
|
// livez) stay on mountHealth above — its fall-through-when-unset is
|
|
// load-bearing.
|
|
mountPlatform(app)
|
|
// The TYPED query-core ops — the v5 composite query engine (query_range, its
|
|
// dry-run preview, variable substitution), the legacy metrics range read and
|
|
// the builder-format echo, attribute autocomplete, the field catalog and the
|
|
// saved explorer views. Same construction: named In, named Out, answered on
|
|
// the runtime, wire unchanged; see querycore.go. Query progress (long-poll
|
|
// and websocket) and raw-data export stay hatches — they are streams, not
|
|
// JSON answers.
|
|
mountQueryCore(app)
|
|
// The last three slices of the first conversion pass. They were DARK: their
|
|
// files landed with the rest of the conversion, but nothing here called them,
|
|
// so 83 typed ops existed in the source and reached no router at all. An
|
|
// uncalled package-level func is legal Go, so the package built and the whole
|
|
// suite passed while every one of those routes still fell through the
|
|
// wildcard — the conversion was true of the source and false of the binary.
|
|
// Route-table arithmetic is what caught it, and it is why the wildcard is
|
|
// gone: with every route named, a slice that is not mounted 404s loudly
|
|
// instead of being quietly answered by a catch-all.
|
|
mountSentryErrors(app)
|
|
mountRulesAlerts(app)
|
|
mountIntegrations(app)
|
|
// The TYPED traces ops — one trace's spans, the trace field catalog and its
|
|
// tuning write, and the three trace-detail views (waterfall, flamegraph,
|
|
// span aggregations); see traces.go.
|
|
mountTraces(app)
|
|
// The TYPED trace-funnel ops — funnel CRUD plus the twelve analytics reads,
|
|
// six over a saved funnel and six over one described inline; see
|
|
// tracefunnels.go.
|
|
mountTraceFunnels(app)
|
|
// The TYPED span-mapper ops — the ingest-time rules that move or copy span
|
|
// attributes into resource attributes; see spanmappers.go.
|
|
mountSpanMappers(app)
|
|
// And the seven that cannot be typed, each named and justified.
|
|
mountHatches(app)
|
|
return nil
|
|
}
|
|
|
|
// mountHatches registers the SEVEN routes that cannot be typed ops, one route
|
|
// literal each, with the reason next to it. This list is meant to shrink and is
|
|
// meant to be hard to grow: adding to it costs a justification in review, where
|
|
// a catch-all cost nothing.
|
|
//
|
|
// A typed op declares an input type, an output type and a success status, and
|
|
// zip projects that declaration into the OpenAPI document, the MCP tool, the CLI
|
|
// command and the by-name call plane. Every route here breaks that declaration
|
|
// in a way that would make the document LIE, and lying in the document is worse
|
|
// than being absent from it — a generated client that trusts a false contract
|
|
// fails at the customer, not at review.
|
|
//
|
|
// Two of these seven sit OUTSIDE /v1/o11y — the ingest routes on eventRoot —
|
|
// which is the second thing a wildcard hides. A catch-all on one root cannot
|
|
// reach a route on another, so it does not merely obscure which routes are
|
|
// un-typed, it obscures which are missing: both were unreachable from the
|
|
// composed binary until every route was named.
|
|
//
|
|
// It was three, and the third is gone rather than moved: /ws/query_progress was
|
|
// the same read as o11yRoot+"/query_progress" over an Upgrade, and one read at
|
|
// two addresses is one address too many. The Upgrade is a property of the
|
|
// request, so both protocols answer at the /v1/o11y address now.
|
|
func mountHatches(app *zip.App) {
|
|
// ── 1. STREAMS: there is no one answer to name ───────────────────────────
|
|
// These never produce a single complete JSON value. relay buffers a whole
|
|
// answer through an httptest recorder before decoding it, so a typed
|
|
// livetail would hang on the first tail, and a typed progress poll would
|
|
// return only after the query it reports on had already finished — which is
|
|
// the one thing a progress endpoint must not do.
|
|
route(app, http.MethodGet, o11yRoot+"/logs/livetail") // unbounded stream of log records
|
|
route(app, http.MethodGet, o11yRoot+"/query_progress") // progress: a long poll, or a websocket when the caller upgrades
|
|
route(app, http.MethodPost, o11yRoot+"/export_raw_data") // chunked CSV/JSONL attachment, X-Response-Complete trailer
|
|
|
|
// ── 2. A FOREIGN PROTOCOL WE RECEIVE ─────────────────────────────────────
|
|
// Sentry-compatible ingest, and the only thing in this table that is not a
|
|
// face. The body is an application/x-sentry-envelope frame, not JSON, and
|
|
// the caller is a Sentry SDK authenticating with a DSN public key rather
|
|
// than a Hanzo session.
|
|
//
|
|
// SO IT ANSWERS AT THE INGEST ENDPOINT, NOT UNDER A PRODUCT'S ROOT (relay.go).
|
|
// eventRoot is what a minted DSN spells, so a beacon has exactly one address
|
|
// to knock on and the face beside it keeps its own gate.
|
|
//
|
|
// The /api/ segment is NOT ours to name: a stock SDK appends its own fixed
|
|
// /api/<project>/envelope/ suffix to whatever DSN path it is given, so
|
|
// renaming it would break every SDK in the field. We RECEIVE this shape; we
|
|
// do not publish it.
|
|
// A declared address never ends in a slash, and fiber matches the SDK's
|
|
// trailing one against it either way.
|
|
route(app, http.MethodPost, eventRoot+"/:project/envelope") // the endpoint a minted DSN names
|
|
route(app, http.MethodPost, eventRoot+"/:project/store") // the same endpoint, single-event form
|
|
route(app, http.MethodPost, o11yRoot+"/api/:project_id/envelope") // the suffix a stock SDK appends
|
|
route(app, http.MethodPost, o11yRoot+"/api/:project_id/store") // the same suffix, single-event form
|
|
}
|