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 three that cannot be typed, each named and justified. mountHatches(app) return nil } // mountHatches registers the THREE 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. // // The stream list 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 }