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]>
This commit is contained in:
2026-09-17 15:02:30 -07:00
co-authored by Hanzo Dev
parent e99200ea1e
commit cae99adb44
29 changed files with 811 additions and 854 deletions
+21 -21
View File
@@ -42,30 +42,30 @@ import (
// parameterised ones so an id can never shadow them — the same defence
// telemetry.go gives its collection routes.
func mountAccess(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
opPost(g, "/roles", createRole, op("CreateRole"), zip.WithStatus(http.StatusCreated))
opGet(g, "/roles", listRoles, op("ListRoles"))
opGet(g, "/roles/:id", getRole, op("GetRole"))
opPut(g, "/roles/:id", updateRole, op("UpdateRole"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/roles/:id", deleteRole, op("DeleteRole"), zip.WithStatus(http.StatusNoContent))
g.Post("/roles", createRole, op("CreateRole"), zip.WithStatus(http.StatusCreated))
g.Get("/roles", listRoles, op("ListRoles"))
g.Get("/roles/:id", getRole, op("GetRole"))
g.Put("/roles/:id", updateRole, op("UpdateRole"), zip.WithStatus(http.StatusNoContent))
g.Delete("/roles/:id", deleteRole, op("DeleteRole"), zip.WithStatus(http.StatusNoContent))
opPost(g, "/service_accounts", createServiceAccount, op("CreateServiceAccount"), zip.WithStatus(http.StatusCreated))
opGet(g, "/service_accounts", listServiceAccounts, op("ListServiceAccounts"))
opGet(g, "/service_accounts/me", getMyServiceAccount, op("GetMyServiceAccount"))
opPut(g, "/service_accounts/me", updateMyServiceAccount, op("UpdateMyServiceAccount"), zip.WithStatus(http.StatusNoContent))
opGet(g, "/service_accounts/:id", getServiceAccount, op("GetServiceAccount"))
opPut(g, "/service_accounts/:id", updateServiceAccount, op("UpdateServiceAccount"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/service_accounts/:id", deleteServiceAccount, op("DeleteServiceAccount"), zip.WithStatus(http.StatusNoContent))
opGet(g, "/service_accounts/:id/roles", listServiceAccountRoles, op("GetServiceAccountRoles"))
opPost(g, "/service_accounts/:id/roles", grantServiceAccountRole, op("CreateServiceAccountRole"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/service_accounts/:id/roles/:rid", revokeServiceAccountRole, op("DeleteServiceAccountRole"), zip.WithStatus(http.StatusNoContent))
opPost(g, "/service_accounts/:id/keys", createServiceAccountKey, op("CreateServiceAccountKey"), zip.WithStatus(http.StatusCreated))
opGet(g, "/service_accounts/:id/keys", listServiceAccountKeys, op("ListServiceAccountKeys"))
opPut(g, "/service_accounts/:id/keys/:fid", updateServiceAccountKey, op("UpdateServiceAccountKey"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/service_accounts/:id/keys/:fid", revokeServiceAccountKey, op("RevokeServiceAccountKey"), zip.WithStatus(http.StatusNoContent))
g.Post("/service_accounts", createServiceAccount, op("CreateServiceAccount"), zip.WithStatus(http.StatusCreated))
g.Get("/service_accounts", listServiceAccounts, op("ListServiceAccounts"))
g.Get("/service_accounts/me", getMyServiceAccount, op("GetMyServiceAccount"))
g.Put("/service_accounts/me", updateMyServiceAccount, op("UpdateMyServiceAccount"), zip.WithStatus(http.StatusNoContent))
g.Get("/service_accounts/:id", getServiceAccount, op("GetServiceAccount"))
g.Put("/service_accounts/:id", updateServiceAccount, op("UpdateServiceAccount"), zip.WithStatus(http.StatusNoContent))
g.Delete("/service_accounts/:id", deleteServiceAccount, op("DeleteServiceAccount"), zip.WithStatus(http.StatusNoContent))
g.Get("/service_accounts/:id/roles", listServiceAccountRoles, op("GetServiceAccountRoles"))
g.Post("/service_accounts/:id/roles", grantServiceAccountRole, op("CreateServiceAccountRole"), zip.WithStatus(http.StatusNoContent))
g.Delete("/service_accounts/:id/roles/:rid", revokeServiceAccountRole, op("DeleteServiceAccountRole"), zip.WithStatus(http.StatusNoContent))
g.Post("/service_accounts/:id/keys", createServiceAccountKey, op("CreateServiceAccountKey"), zip.WithStatus(http.StatusCreated))
g.Get("/service_accounts/:id/keys", listServiceAccountKeys, op("ListServiceAccountKeys"))
g.Put("/service_accounts/:id/keys/:fid", updateServiceAccountKey, op("UpdateServiceAccountKey"), zip.WithStatus(http.StatusNoContent))
g.Delete("/service_accounts/:id/keys/:fid", revokeServiceAccountKey, op("RevokeServiceAccountKey"), zip.WithStatus(http.StatusNoContent))
opPost(g, "/authz/check", checkAccess, op("AuthzCheck"))
g.Post("/authz/check", checkAccess, op("AuthzCheck"))
}
// ── roles ─────────────────────────────────────────────────────────────────────
-84
View File
@@ -1,84 +0,0 @@
package o11y
// AN OPERATION'S ADDRESS IS A FACT OF ITS REGISTRATION, NOT AN ARGUMENT OF ITS
// BODY.
//
// Every op in this table is declared at an address and then has to reach the
// runtime that implements that address. Until now it reached it by saying the
// address a second time, by hand, inside the handler:
//
// opGet(g, "/traces/:traceId", traceSpans) // the declaration
// relay(ctx, "GET", o11yRoot+"/traces/"+in.TraceID, …) // the body
//
// Two spellings of one value, 366 times over, and the second one is what got
// requested — so the first could stop agreeing with it and never be contradicted.
// It has: three of these addresses declare {traceId} where the runtime registered
// {traceID}. A router matches by POSITION, so a parameter's name is invisible to
// it and the disagreement cost nothing until something wanted to look an address
// up by name. That is exactly what the seam now does.
//
// So the address travels WITH the call. The declaration verbs (claim.go) wrap
// every op in [addressed], which puts the address the op was registered at into
// the context, and relay reads it there. The op's body states no method and no
// path at all, which is the honest end state: which endpoint was called is not
// something the body decides.
//
// The concrete path comes from [zip.Address], the inverse of the binding zip
// already performed — zip put the matched segments INTO the input, so reading
// them back out through the same rule cannot disagree with what arrived. The 119
// hand-built concatenations it replaces each re-derived that by eye.
import (
"context"
"github.com/zap-proto/zip"
)
// addr is one operation's address: the method, the public TEMPLATE that names it
// at the seam, and the CONCRETE path this particular call is for.
//
// Two spellings live here on purpose and they are two different values. The
// template is the address's NAME — "/v1/o11y/traces/{traceId}" — the string the
// document publishes and the string the runtime's registration recorded, so it is
// what resolves a handler. The path is where THIS call is going —
// "/v1/o11y/traces/abc-123" — so it is what the request carries.
type addr struct {
method string
template string
path string
}
// addrKey names the context slot an op's address rides in. A zero-size unexported
// type: unforgeable from outside this package, so nothing can plant an address an
// op was not registered at.
type addrKey struct{}
// addressed states an op's address once and carries it into the call.
//
// The template is computed at REGISTRATION — once per op for the life of the
// process — because it is a property of the declaration. Only the concrete path
// is per-call, because only it depends on the input.
//
// The full address is the target's own prefix plus the declared path, the same
// composition [claims] reads and the same one zip's registry records, so the
// three cannot name different addresses.
func addressed[In, Out any](root, method, path string, fn zip.TypedHandler[In, Out]) zip.TypedHandler[In, Out] {
pattern := root + path
template := zip.Template(pattern)
return func(ctx context.Context, in *In) (*Out, error) {
return fn(context.WithValue(ctx, addrKey{}, addr{
method: method,
template: template,
path: zip.Address(pattern, in),
}), in)
}
}
// addressOf is the address of the op whose body is running, and whether there is
// one. There is not exactly when an op's function is called directly rather than
// through the declaration that registered it — a programming error, and one worth
// naming rather than papering over with a guessed address.
func addressOf(ctx context.Context) (addr, bool) {
a, ok := ctx.Value(addrKey{}).(addr)
return a, ok
}
+22 -22
View File
@@ -40,36 +40,36 @@ import (
// service catalog first, then the messaging-queue groups in the order the mux
// tree declares them, then the third-party API overview.
func mountAPM(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// The service catalog: /services collection, /service/* breakdowns.
opPost(g, "/services", services)
opGet(g, "/services/list", serviceNames)
opPost(g, "/service/top_operations", topOperations)
opPost(g, "/service/top_level_operations", topLevelOperations)
opPost(g, "/service/entry_point_operations", entryPointOperations)
g.Post("/services", services)
g.Get("/services/list", serviceNames)
g.Post("/service/top_operations", topOperations)
g.Post("/service/top_level_operations", topLevelOperations)
g.Post("/service/entry_point_operations", entryPointOperations)
// The messaging-queue surface: the queue overview, then the Kafka
// onboarding, partition-latency, consumer-lag, topic-throughput and
// span-evaluation groups.
opPost(g, "/messaging-queues/queue-overview", queueOverview)
opPost(g, "/messaging-queues/kafka/onboarding/producers", producersOnboarding)
opPost(g, "/messaging-queues/kafka/onboarding/consumers", consumersOnboarding)
opPost(g, "/messaging-queues/kafka/onboarding/kafka", kafkaOnboarding)
opPost(g, "/messaging-queues/kafka/partition-latency/overview", partitionLatency)
opPost(g, "/messaging-queues/kafka/partition-latency/consumer", consumerPartitionLatency)
opPost(g, "/messaging-queues/kafka/consumer-lag/producer-details", producerLagDetails)
opPost(g, "/messaging-queues/kafka/consumer-lag/consumer-details", consumerLagDetails)
opPost(g, "/messaging-queues/kafka/consumer-lag/network-latency", consumerLagNetwork)
opPost(g, "/messaging-queues/kafka/topic-throughput/producer", producerThroughput)
opPost(g, "/messaging-queues/kafka/topic-throughput/producer-details", producerThroughputDetails)
opPost(g, "/messaging-queues/kafka/topic-throughput/consumer", consumerThroughput)
opPost(g, "/messaging-queues/kafka/topic-throughput/consumer-details", consumerThroughputDetails)
opPost(g, "/messaging-queues/kafka/span/evaluation", spanEvaluation)
g.Post("/messaging-queues/queue-overview", queueOverview)
g.Post("/messaging-queues/kafka/onboarding/producers", producersOnboarding)
g.Post("/messaging-queues/kafka/onboarding/consumers", consumersOnboarding)
g.Post("/messaging-queues/kafka/onboarding/kafka", kafkaOnboarding)
g.Post("/messaging-queues/kafka/partition-latency/overview", partitionLatency)
g.Post("/messaging-queues/kafka/partition-latency/consumer", consumerPartitionLatency)
g.Post("/messaging-queues/kafka/consumer-lag/producer-details", producerLagDetails)
g.Post("/messaging-queues/kafka/consumer-lag/consumer-details", consumerLagDetails)
g.Post("/messaging-queues/kafka/consumer-lag/network-latency", consumerLagNetwork)
g.Post("/messaging-queues/kafka/topic-throughput/producer", producerThroughput)
g.Post("/messaging-queues/kafka/topic-throughput/producer-details", producerThroughputDetails)
g.Post("/messaging-queues/kafka/topic-throughput/consumer", consumerThroughput)
g.Post("/messaging-queues/kafka/topic-throughput/consumer-details", consumerThroughputDetails)
g.Post("/messaging-queues/kafka/span/evaluation", spanEvaluation)
// The third-party API overview: external domains and one domain's detail.
opPost(g, "/third-party-apis/overview/list", domainList)
opPost(g, "/third-party-apis/overview/domain", domainInfo)
g.Post("/third-party-apis/overview/list", domainList)
g.Post("/third-party-apis/overview/domain", domainInfo)
}
// ── the service catalog ───────────────────────────────────────────────────────
-244
View File
@@ -1,244 +0,0 @@
package o11y
// A HOST MAY ALREADY SERVE AN ADDRESS THIS TABLE DECLARES, and when it does it
// has to be able to say so.
//
// This module owns /v1/o11y as its own service address (HIP-0106) and, since the
// conversion, declares every route under it by name — there is no wildcard left
// for a host's own route to win against. That is the property that makes the
// table honest, and it is also what turns 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 get precedence, it gets a panic.
//
// In-order shadowing used to make the overlap invisible. It was never a decision,
// only an artefact of registration order, and the two handlers it hid could drift
// apart forever without anyone being told. Making the host NAME the addresses it
// takes is the same fact written down where it can be read.
//
// So the seam is a statement of ownership, not a filter: [Claimed] says "the host
// serves these, this table does not declare them". Everything else is declared
// exactly as before. An address named here that this table never declares is
// inert rather than an error — the host is describing what IT serves, and a
// table that stopped declaring an address should not turn into a boot failure in
// every host that had claimed it.
import (
"net/http"
"github.com/zap-proto/zip"
)
// Option configures one [Mount].
type Option func(*conf)
// conf is one Mount's settings.
type conf struct {
// claimed holds "METHOD /path" for every address the host serves itself.
claimed map[string]bool
}
// Claimed names addresses the HOST serves itself, which this table therefore does
// not declare. Each is 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.
//
// The path is the FULL public path, not one relative to the mount root: a host
// reads it off the route it registered, and an address is the same value on both
// sides of this seam or it is not the same address.
//
// A claim is the host taking responsibility for that address. Nothing about the
// runtime changes — the operation is still there, still reachable on the o11y
// runtime's own router — but this table stops publishing it, so the host's
// handler is the one and only answer at that address in the host's process.
func Claimed(addrs ...string) Option {
return func(c *conf) {
if c.claimed == nil {
c.claimed = make(map[string]bool, len(addrs))
}
for _, a := range addrs {
c.claimed[a] = true
}
}
}
// mounting is the settings of the Mount currently running. Mount is a
// composition-root call — once per process, before it serves — and every
// declaration below happens inside it, so the set is written once and read
// during that one call. It is nil at every other moment, which is what makes an
// unclaimed mount cost nothing.
var mounting *conf
// claims reports whether the host has taken the address this declaration would
// register at.
//
// The full address is the target's own prefix plus the declared path, so this
// reads the same whether an op is declared on a rooted group (the usual case,
// where path is "/metrics") or straight on the app with a full path. The prefix
// comes from [*zip.Group], the interface every declaration already goes
// through, so nothing here needs to know which group it was handed.
func claims(root, method, path string) bool {
if mounting == nil || len(mounting.claimed) == 0 {
return false
}
return mounting.claimed[method+" "+root+path]
}
// under is the OpTarget that registers on app at a ROOT — the one place this
// package's two public roots become route prefixes.
//
// It replaces rooted{app.Group(o11yRoot), o11yRoot}, and the difference is the operation ids.
// zip qualifies an op's id by the prefix of the OCCURRENCE it answers under
// (walk.go occurrenceID), because a definition included TWICE declares one id
// and produces two operations, and an OpenAPI document cannot hold two
// operations under one operationId. Sound rule — and o11yRoot is not that kind
// of prefix. It is not a composition point a host chose; it is this service's
// own address, fixed by HIP-0106 and spelled above. Reached through a Group it
// read as one, and all 353 published ids became "v1.o11y.CreateRole" — renaming
// every MCP tool, OpenAPI operationId, CLI command and generated SDK method in a
// patch release.
//
// zip v1.24.2 fixed HALF of that: a DECLARED id (WithOperationID, or the op
// helper below) now survives composition verbatim, so a Group can no longer
// rename one. It is still only half. 217 of this package's 353 ops declare an
// id; the other 136 take zip's shape-derived id, and that half IS still
// qualified by the occurrence's prefix — deterministically and correctly, since
// an undeclared id carries no promise to keep. Reached through a Group those 136
// would publish as "v1.o11y.get_v1_o11y_…" instead of "get_v1_o11y_…", which is
// the same rename in a smaller font. So this type stays until the count is 353,
// and TestUnderIsStillLoadBearing measures it rather than trusting this
// sentence.
//
// An occurrence at the ROOT prefix is unqualified, so this registers the ops on
// app itself and lets OpScope.Prefix do what it documents — prepend to the op's
// path. Same method, same full path, same middleware; the id is the one the
// declaration wrote. That is also what mount.go's PATH UNTOUCHED already claimed
// and what mountHealth already does by hand, so the Group was the odd one out.
//
// The middleware comes from app's own scope rather than being zeroed: Group
// inherits the app's wrap, and dropping it here would silently unwrap every
// typed op.
// rooted is a group together with the path it answers under. The group does the
// registering; the root is what an address is measured from.
type rooted struct {
*zip.Group
root string
}
// The five declaration verbs, each the zip one plus the claim check. Every typed
// op in this package goes through these rather than through zip directly, which
// is what makes a claim mean the same thing at all 358 declarations instead of
// at the handful someone remembered.
//
// They exist because the check needs the ADDRESS, and an address is the target's
// prefix plus the path — known only at the declaration, not at the target. zip
// resolves a target once, without a path, so a target cannot refuse one address
// and accept another; the verb can.
func opGet[In, Out any](on rooted, path string, fn zip.TypedHandler[In, Out], opts ...zip.OpOption) {
if claims(on.root, http.MethodGet, path) {
return
}
on.Get(path, addressed(on.root, http.MethodGet, path, fn), opts...)
}
func opPost[In, Out any](on rooted, path string, fn zip.TypedHandler[In, Out], opts ...zip.OpOption) {
if claims(on.root, http.MethodPost, path) {
return
}
on.Post(path, addressed(on.root, http.MethodPost, path, fn), opts...)
}
func opPut[In, Out any](on rooted, path string, fn zip.TypedHandler[In, Out], opts ...zip.OpOption) {
if claims(on.root, http.MethodPut, path) {
return
}
on.Put(path, addressed(on.root, http.MethodPut, path, fn), opts...)
}
func opPatch[In, Out any](on rooted, path string, fn zip.TypedHandler[In, Out], opts ...zip.OpOption) {
if claims(on.root, http.MethodPatch, path) {
return
}
on.Patch(path, addressed(on.root, http.MethodPatch, path, fn), opts...)
}
func opDelete[In, Out any](on rooted, path string, fn zip.TypedHandler[In, Out], opts ...zip.OpOption) {
if claims(on.root, http.MethodDelete, path) {
return
}
on.Delete(path, addressed(on.root, http.MethodDelete, path, fn), opts...)
}
// route declares one of the ten escape hatches — a raw route rather than a
// typed op (see mountHatches) — and honours a claim the same way. A hatch is
// still an address, so a host has to be able to take one.
//
// It takes no handler, because a hatch's handler is the same fact a typed op's
// is: the runtime's answer at that address. Naming the address is the whole
// registration.
func route(app *zip.App, method, path string) {
// A hatch names its whole address, so there is no root to measure from.
if claims("", method, path) {
return
}
switch method {
case http.MethodGet, http.MethodPost:
default:
panic("o11y: route: unsupported method " + method)
}
app.Raw(method, path, hatch(method, zip.Template(path)))
}
// bridge is this package's ONE net/http adapter: every route that answers with a
// handler the HOST installed is registered through here. (A typed op does not
// come this way — relay drives the same handler with a recorder, from inside the
// op, and never touches the router.)
//
// What it adapts is net/http on the far side by construction: a runtime resolves
// to an http.Handler ([Runtime]), and a health handler is three
// http.HandlerFuncs (factory.Handler). Neither is a value this package could ask
// for natively — both are handed in by the host — so the request has to be
// rendered as an http.Request once, here, to reach either.
//
// pick runs per request because both callers choose late: a hatch reads the
// runtime SetRuntime installed, a probe the handler SetHealth did, and either can
// change while the process serves. Only the CHOICE is late — the adapter itself
// is built once, at registration. Building it per request is what this replaces:
// probe called zip.AdaptNetHTTP inside its own handler, so every liveness poll
// allocated an adapter and wrote zip's terminal marker into a process-wide map
// again, to answer a request whose shape has not changed since boot.
func bridge(pick func() http.Handler) zip.Handler {
return zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pick().ServeHTTP(w, r)
}))
}
// answer is the runtime's handler for one address, or the refusal that says
// which of the two reasons it has none. The refusal is net/http's own shape —
// text/plain and nosniff — because it is written on the net/http side of the
// bridge, and health_test.go's table pins it that way.
func answer(method, template string) http.Handler {
h, err := at(method, template)
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
})
}
return h
}
// hatch serves ONE address by handing the request straight to the runtime's
// handler for it — no recorder, no decode, no re-encode.
//
// That pass-through is the point. Every route registered this way is one whose
// answer cannot be named: an unbounded log tail, a long poll, a chunked export, a
// 303 whose whole content is a Location, a Sentry envelope frame. relay buffers a
// complete answer before it can decode one, so a typed op would hang on the first
// tail and would return a progress report only after the query it reports on had
// finished. Here the runtime's handler writes to the caller's own
// ResponseWriter, so a stream stays a stream and a header stays a header.
func hatch(method, template string) zip.Handler {
return bridge(func() http.Handler { return answer(method, template) })
}
-131
View File
@@ -1,131 +0,0 @@
package o11y_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/o11y"
"github.com/zap-proto/zip"
)
// What a claim has to be true of: the claimed address is NOT declared, every
// other address still is, and a host that claims nothing sees the whole table.
// A seam that skipped more than it was asked to would delete operations
// silently, which is the failure the wildcard used to hide.
// declaredAt reports whether this METHOD and path is in the table. With no
// runtime behind it a declared route answers 503
// (TestNamedRouteWithoutRuntimeReturns503), so the two refusals below are
// unambiguous — neither can be a handler's own answer.
//
// Both refusals count as "not declared", and the 405 is the interesting one: an
// address is a method AND a path, so claiming POST /query_range while GET
// /query_range stays declared leaves the PATH routable and the method not. The
// router says 405 to that, not 404, and reading only 404 would have called the
// claim a failure when it had done exactly what it was asked.
func declaredAt(t *testing.T, app *zip.App, method, target string) bool {
t.Helper()
resp, err := app.Test(httptest.NewRequest(method, target, nil))
if err != nil {
t.Fatalf("Test %s %s: %v", method, target, err)
}
return resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed
}
// mountClaiming builds an app with the given claims. With no runtime installed,
// a declared address answers 503 rather than reaching one — which is what lets
// this file read "declared" off the answer without standing a runtime up.
func mountClaiming(t *testing.T, opts ...o11y.Option) *zip.App {
t.Helper()
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := o11y.Use(app, opts...); err != nil {
t.Fatalf("Mount: %v", err)
}
o11y.SetRuntime(nil)
return app
}
// The whole table is declared when nothing is claimed. This is the control: it
// is what makes the absences in the next test attributable to the claim rather
// than to the address never having been there.
func TestUnclaimedMountDeclaresEverything(t *testing.T) {
app := mountClaiming(t)
for _, a := range []struct{ method, path string }{
{http.MethodGet, "/v1/o11y/logs"},
{http.MethodGet, "/v1/o11y/metrics"},
{http.MethodPost, "/v1/o11y/query_range"},
{http.MethodGet, "/v1/o11y/logs/livetail"}, // a hatch
} {
if !declaredAt(t, app, a.method, a.path) {
t.Errorf("%s %s is not declared by an unclaimed Mount", a.method, a.path)
}
}
}
// A claimed address is not declared, and its NEIGHBOURS are untouched. The
// neighbours are the point: /metrics and /metrics/stats differ by one segment,
// and a seam that matched by prefix would take the second one with the first.
func TestClaimedAddressIsNotDeclared(t *testing.T) {
app := mountClaiming(t, o11y.Claimed(
"GET /v1/o11y/metrics",
"POST /v1/o11y/query_range",
))
for _, a := range []struct{ method, path string }{
{http.MethodGet, "/v1/o11y/metrics"},
{http.MethodPost, "/v1/o11y/query_range"},
} {
if declaredAt(t, app, a.method, a.path) {
t.Errorf("%s %s was claimed by the host but is still declared", a.method, a.path)
}
}
// Everything the claim did NOT name is still there: the sibling under the
// claimed path, the same path under a different METHOD, and an unrelated op.
for _, a := range []struct{ method, path string }{
{http.MethodPost, "/v1/o11y/metrics/stats"}, // sibling below a claimed path
{http.MethodGet, "/v1/o11y/query_range"}, // same path, method not claimed
{http.MethodPost, "/v1/o11y/query_range/format"}, // sibling below a claimed path
{http.MethodGet, "/v1/o11y/logs"}, // unrelated op
} {
if !declaredAt(t, app, a.method, a.path) {
t.Errorf("%s %s was NOT claimed but the claim took it anyway", a.method, a.path)
}
}
}
// A hatch is an address too, so a host can take one. The hatches are raw routes
// rather than typed ops, and they went through a different verb — this is what
// keeps the seam from being true of 353 addresses and false of ten.
func TestClaimedHatchIsNotDeclared(t *testing.T) {
app := mountClaiming(t, o11y.Claimed("GET /v1/o11y/logs/livetail"))
if declaredAt(t, app, http.MethodGet, "/v1/o11y/logs/livetail") {
t.Error("GET /v1/o11y/logs/livetail was claimed but is still declared")
}
if !declaredAt(t, app, http.MethodGet, "/v1/o11y/query_progress") {
t.Error("an unclaimed hatch stopped being declared")
}
}
// A claim for an address this table does not declare is INERT, not an error. The
// host is describing what it serves; a table that stopped declaring an address
// must not turn into a boot failure in every host that had claimed it.
func TestClaimForAnUnknownAddressIsInert(t *testing.T) {
app := mountClaiming(t, o11y.Claimed("GET /v1/o11y/no-such-route", "DELETE /v1/o11y/logs"))
if !declaredAt(t, app, http.MethodGet, "/v1/o11y/logs") {
t.Error("a claim on a method this table does not declare removed the one it does")
}
}
// The claim set does not outlive its Mount. It is read by the declaration verbs
// during the call, so a second Mount with no options must see the whole table
// again — otherwise one host's claim would leak into the next app in the same
// process, which is exactly what a test binary does.
func TestClaimDoesNotLeakToTheNextMount(t *testing.T) {
_ = mountClaiming(t, o11y.Claimed("GET /v1/o11y/metrics"))
next := mountClaiming(t)
if !declaredAt(t, next, http.MethodGet, "/v1/o11y/metrics") {
t.Error("the previous Mount's claim leaked into a later, unclaimed Mount")
}
}
+23 -23
View File
@@ -55,41 +55,41 @@ import (
// carries the operation id its mux registration always declared, so the document
// names it exactly as it was named before.
func mountDashboards(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// Dashboards — list, per-user list, create, clone, read, update, patch, delete.
opGet(g, "/dashboards", dashboardListV2, zip.WithOperationID("ListDashboardsV2"))
opGet(g, "/users/me/dashboards", dashboardListForUserV2, zip.WithOperationID("ListDashboardsForUserV2"))
opPost(g, "/dashboards", dashboardCreateV2, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDashboardV2"))
opPost(g, "/dashboards/:id/clone", dashboardCloneV2, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CloneDashboardV2"))
opGet(g, "/dashboards/:id", dashboardGetV2, zip.WithOperationID("GetDashboardV2"))
opPut(g, "/dashboards/:id", dashboardUpdateV2, zip.WithOperationID("UpdateDashboardV2"))
opPatch(g, "/dashboards/:id", dashboardPatchV2, zip.WithOperationID("PatchDashboardV2"))
opDelete(g, "/dashboards/:id", dashboardDeleteV2, zip.WithOperationID("DeleteDashboardV2"))
g.Get("/dashboards", dashboardListV2, zip.WithOperationID("ListDashboardsV2"))
g.Get("/users/me/dashboards", dashboardListForUserV2, zip.WithOperationID("ListDashboardsForUserV2"))
g.Post("/dashboards", dashboardCreateV2, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDashboardV2"))
g.Post("/dashboards/:id/clone", dashboardCloneV2, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CloneDashboardV2"))
g.Get("/dashboards/:id", dashboardGetV2, zip.WithOperationID("GetDashboardV2"))
g.Put("/dashboards/:id", dashboardUpdateV2, zip.WithOperationID("UpdateDashboardV2"))
g.Patch("/dashboards/:id", dashboardPatchV2, zip.WithOperationID("PatchDashboardV2"))
g.Delete("/dashboards/:id", dashboardDeleteV2, zip.WithOperationID("DeleteDashboardV2"))
// Lock / unlock — only the creator or an org admin; the runtime decides.
opPut(g, "/dashboards/:id/lock", dashboardLockV2, zip.WithOperationID("LockDashboardV2"))
opDelete(g, "/dashboards/:id/lock", dashboardUnlockV2, zip.WithOperationID("UnlockDashboardV2"))
g.Put("/dashboards/:id/lock", dashboardLockV2, zip.WithOperationID("LockDashboardV2"))
g.Delete("/dashboards/:id/lock", dashboardUnlockV2, zip.WithOperationID("UnlockDashboardV2"))
// Per-user pins — a viewer bookmark, mutating only the caller's pin list.
opPut(g, "/users/me/dashboards/:id/pins", dashboardPinV2, zip.WithOperationID("PinDashboardV2"))
opDelete(g, "/users/me/dashboards/:id/pins", dashboardUnpinV2, zip.WithOperationID("UnpinDashboardV2"))
g.Put("/users/me/dashboards/:id/pins", dashboardPinV2, zip.WithOperationID("PinDashboardV2"))
g.Delete("/users/me/dashboards/:id/pins", dashboardUnpinV2, zip.WithOperationID("UnpinDashboardV2"))
// Saved views — org-shared dashboard-listing state.
opGet(g, "/dashboard_views", dashboardListViews, zip.WithOperationID("ListDashboardViews"))
opPost(g, "/dashboard_views", dashboardCreateView, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDashboardView"))
opPut(g, "/dashboard_views/:id", dashboardUpdateView, zip.WithOperationID("UpdateDashboardView"))
opDelete(g, "/dashboard_views/:id", dashboardDeleteView, zip.WithOperationID("DeleteDashboardView"))
g.Get("/dashboard_views", dashboardListViews, zip.WithOperationID("ListDashboardViews"))
g.Post("/dashboard_views", dashboardCreateView, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDashboardView"))
g.Put("/dashboard_views/:id", dashboardUpdateView, zip.WithOperationID("UpdateDashboardView"))
g.Delete("/dashboard_views/:id", dashboardDeleteView, zip.WithOperationID("DeleteDashboardView"))
// Public sharing config — org-admin only.
opPost(g, "/dashboards/:id/public", dashboardCreatePublic, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreatePublicDashboard"))
opGet(g, "/dashboards/:id/public", dashboardGetPublic, zip.WithOperationID("GetPublicDashboard"))
opPut(g, "/dashboards/:id/public", dashboardUpdatePublic, zip.WithOperationID("UpdatePublicDashboard"))
opDelete(g, "/dashboards/:id/public", dashboardDeletePublic, zip.WithOperationID("DeletePublicDashboard"))
g.Post("/dashboards/:id/public", dashboardCreatePublic, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreatePublicDashboard"))
g.Get("/dashboards/:id/public", dashboardGetPublic, zip.WithOperationID("GetPublicDashboard"))
g.Put("/dashboards/:id/public", dashboardUpdatePublic, zip.WithOperationID("UpdatePublicDashboard"))
g.Delete("/dashboards/:id/public", dashboardDeletePublic, zip.WithOperationID("DeletePublicDashboard"))
// Anonymous public reads — scoped to the public dashboard's read scope.
opGet(g, "/public/dashboards/:id", dashboardGetPublicData, zip.WithOperationID("GetPublicDashboardData"))
opGet(g, "/public/dashboards/:id/widgets/:idx/query_range", dashboardGetPublicWidgetQueryRange, zip.WithOperationID("GetPublicDashboardWidgetQueryRange"))
g.Get("/public/dashboards/:id", dashboardGetPublicData, zip.WithOperationID("GetPublicDashboardData"))
g.Get("/public/dashboards/:id/widgets/:idx/query_range", dashboardGetPublicWidgetQueryRange, zip.WithOperationID("GetPublicDashboardWidgetQueryRange"))
}
// ── the twenty-two operations ───────────────────────────────────────────────────
+69
View File
@@ -0,0 +1,69 @@
package o11y
// The routes that cannot be typed ops, and the net/http seam one is reached
// through. A hatch hands the request to the runtime's handler for its own
// address, unbuffered, so a stream stays a stream.
import (
"net/http"
"github.com/zap-proto/zip"
)
// route registers one hatch at its full public address. See mountHatches for
// why each of them cannot be a typed op.
func route(app *zip.App, method, path string) {
app.Raw(method, path, hatch(method, zip.Template(path)))
}
// bridge is this package's ONE net/http adapter: every route that answers with a
// handler the HOST installed is registered through here. (A typed op does not
// come this way — relay drives the same handler with a recorder, from inside the
// op, and never touches the router.)
//
// What it adapts is net/http on the far side by construction: a runtime resolves
// to an http.Handler ([Runtime]), and a health handler is three
// http.HandlerFuncs (factory.Handler). Neither is a value this package could ask
// for natively — both are handed in by the host — so the request has to be
// rendered as an http.Request once, here, to reach either.
//
// pick runs per request because both callers choose late: a hatch reads the
// runtime SetRuntime installed, a probe the handler SetHealth did, and either can
// change while the process serves. Only the CHOICE is late — the adapter itself
// is built once, at registration. Building it per request is what this replaces:
// probe called zip.AdaptNetHTTP inside its own handler, so every liveness poll
// allocated an adapter and wrote zip's terminal marker into a process-wide map
// again, to answer a request whose shape has not changed since boot.
func bridge(pick func() http.Handler) zip.Handler {
return zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pick().ServeHTTP(w, r)
}))
}
// answer is the runtime's handler for one address, or the refusal that says
// which of the two reasons it has none. The refusal is net/http's own shape —
// text/plain and nosniff — because it is written on the net/http side of the
// bridge, and health_test.go's table pins it that way.
func answer(method, template string) http.Handler {
h, err := at(method, template)
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
})
}
return h
}
// hatch serves ONE address by handing the request straight to the runtime's
// handler for it — no recorder, no decode, no re-encode.
//
// That pass-through is the point. Every route registered this way is one whose
// answer cannot be named: an unbounded log tail, a long poll, a chunked export, a
// 303 whose whole content is a Location, a Sentry envelope frame. relay buffers a
// complete answer before it can decode one, so a typed op would hang on the first
// tail and would return a progress report only after the query it reports on had
// finished. Here the runtime's handler writes to the caller's own
// ResponseWriter, so a stream stays a stream and a header stays a header.
func hatch(method, template string) zip.Handler {
return bridge(func() http.Handler { return answer(method, template) })
}
+1 -1
View File
@@ -59,7 +59,7 @@ func mountHealth(app *zip.App) {
}
// The fall-through is the runtime's handler for the probe's own address, reached
// through the same hatch every un-typed route uses (claim.go).
// through the same hatch every un-typed route uses (hatch.go).
//
// It used to be c.Next(), which worked only because a /v1/o11y/* catch-all was
// registered after the probes and caught whatever they declined. Every route is
+13 -13
View File
@@ -54,25 +54,25 @@ import (
// mountIdentity registers the identity face's typed ops on the native router.
func mountIdentity(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// who the caller is
opGet(g, "/users/me", getMyUser, zip.WithOperationID("GetMyUser"))
g.Get("/users/me", getMyUser, zip.WithOperationID("GetMyUser"))
// my organization and its quick filters
opGet(g, "/orgs/me", getMyOrg, zip.WithOperationID("GetMyOrganization"))
opPut(g, "/orgs/me", updateMyOrg, zip.WithOperationID("UpdateMyOrganization"))
opGet(g, "/orgs/me/filters", getQuickFilters, zip.WithOperationID("GetQuickFilters"))
opGet(g, "/orgs/me/filters/:signal", getSignalFilters, zip.WithOperationID("GetSignalFilters"))
opPut(g, "/orgs/me/filters", updateQuickFilters, zip.WithOperationID("UpdateQuickFilters"))
g.Get("/orgs/me", getMyOrg, zip.WithOperationID("GetMyOrganization"))
g.Put("/orgs/me", updateMyOrg, zip.WithOperationID("UpdateMyOrganization"))
g.Get("/orgs/me/filters", getQuickFilters, zip.WithOperationID("GetQuickFilters"))
g.Get("/orgs/me/filters/:signal", getSignalFilters, zip.WithOperationID("GetSignalFilters"))
g.Put("/orgs/me/filters", updateQuickFilters, zip.WithOperationID("UpdateQuickFilters"))
// preferences, per user and per org
opGet(g, "/user/preferences", listUserPreferences, zip.WithOperationID("ListUserPreferences"))
opGet(g, "/user/preferences/:name", getUserPreference, zip.WithOperationID("GetUserPreference"))
opPut(g, "/user/preferences/:name", updateUserPreference, zip.WithOperationID("UpdateUserPreference"))
opGet(g, "/org/preferences", listOrgPreferences, zip.WithOperationID("ListOrgPreferences"))
opGet(g, "/org/preferences/:name", getOrgPreference, zip.WithOperationID("GetOrgPreference"))
opPut(g, "/org/preferences/:name", updateOrgPreference, zip.WithOperationID("UpdateOrgPreference"))
g.Get("/user/preferences", listUserPreferences, zip.WithOperationID("ListUserPreferences"))
g.Get("/user/preferences/:name", getUserPreference, zip.WithOperationID("GetUserPreference"))
g.Put("/user/preferences/:name", updateUserPreference, zip.WithOperationID("UpdateUserPreference"))
g.Get("/org/preferences", listOrgPreferences, zip.WithOperationID("ListOrgPreferences"))
g.Get("/org/preferences/:name", getOrgPreference, zip.WithOperationID("GetOrgPreference"))
g.Put("/org/preferences/:name", updateOrgPreference, zip.WithOperationID("UpdateOrgPreference"))
}
// ── who the caller is ─────────────────────────────────────────────────────────
+45 -45
View File
@@ -44,63 +44,63 @@ import (
// eleven infra resources, the ten infra_monitoring rollups, and the setup
// checks.
func mountInfra(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
opGet(g, "/hosts/attribute_keys", hostAttributeKeys)
opGet(g, "/hosts/attribute_values", hostAttributeValues)
opPost(g, "/hosts/list", hostList)
g.Get("/hosts/attribute_keys", hostAttributeKeys)
g.Get("/hosts/attribute_values", hostAttributeValues)
g.Post("/hosts/list", hostList)
opGet(g, "/processes/attribute_keys", processAttributeKeys)
opGet(g, "/processes/attribute_values", processAttributeValues)
opPost(g, "/processes/list", processList)
g.Get("/processes/attribute_keys", processAttributeKeys)
g.Get("/processes/attribute_values", processAttributeValues)
g.Post("/processes/list", processList)
opGet(g, "/pods/attribute_keys", podAttributeKeys)
opGet(g, "/pods/attribute_values", podAttributeValues)
opPost(g, "/pods/list", podList)
g.Get("/pods/attribute_keys", podAttributeKeys)
g.Get("/pods/attribute_values", podAttributeValues)
g.Post("/pods/list", podList)
opGet(g, "/pvcs/attribute_keys", pvcAttributeKeys)
opGet(g, "/pvcs/attribute_values", pvcAttributeValues)
opPost(g, "/pvcs/list", pvcList)
g.Get("/pvcs/attribute_keys", pvcAttributeKeys)
g.Get("/pvcs/attribute_values", pvcAttributeValues)
g.Post("/pvcs/list", pvcList)
opGet(g, "/nodes/attribute_keys", nodeAttributeKeys)
opGet(g, "/nodes/attribute_values", nodeAttributeValues)
opPost(g, "/nodes/list", nodeList)
g.Get("/nodes/attribute_keys", nodeAttributeKeys)
g.Get("/nodes/attribute_values", nodeAttributeValues)
g.Post("/nodes/list", nodeList)
opGet(g, "/namespaces/attribute_keys", namespaceAttributeKeys)
opGet(g, "/namespaces/attribute_values", namespaceAttributeValues)
opPost(g, "/namespaces/list", namespaceList)
g.Get("/namespaces/attribute_keys", namespaceAttributeKeys)
g.Get("/namespaces/attribute_values", namespaceAttributeValues)
g.Post("/namespaces/list", namespaceList)
opGet(g, "/clusters/attribute_keys", clusterAttributeKeys)
opGet(g, "/clusters/attribute_values", clusterAttributeValues)
opPost(g, "/clusters/list", clusterList)
g.Get("/clusters/attribute_keys", clusterAttributeKeys)
g.Get("/clusters/attribute_values", clusterAttributeValues)
g.Post("/clusters/list", clusterList)
opGet(g, "/deployments/attribute_keys", deploymentAttributeKeys)
opGet(g, "/deployments/attribute_values", deploymentAttributeValues)
opPost(g, "/deployments/list", deploymentList)
g.Get("/deployments/attribute_keys", deploymentAttributeKeys)
g.Get("/deployments/attribute_values", deploymentAttributeValues)
g.Post("/deployments/list", deploymentList)
opGet(g, "/daemonsets/attribute_keys", daemonSetAttributeKeys)
opGet(g, "/daemonsets/attribute_values", daemonSetAttributeValues)
opPost(g, "/daemonsets/list", daemonSetList)
g.Get("/daemonsets/attribute_keys", daemonSetAttributeKeys)
g.Get("/daemonsets/attribute_values", daemonSetAttributeValues)
g.Post("/daemonsets/list", daemonSetList)
opGet(g, "/statefulsets/attribute_keys", statefulSetAttributeKeys)
opGet(g, "/statefulsets/attribute_values", statefulSetAttributeValues)
opPost(g, "/statefulsets/list", statefulSetList)
g.Get("/statefulsets/attribute_keys", statefulSetAttributeKeys)
g.Get("/statefulsets/attribute_values", statefulSetAttributeValues)
g.Post("/statefulsets/list", statefulSetList)
opGet(g, "/jobs/attribute_keys", jobAttributeKeys)
opGet(g, "/jobs/attribute_values", jobAttributeValues)
opPost(g, "/jobs/list", jobList)
g.Get("/jobs/attribute_keys", jobAttributeKeys)
g.Get("/jobs/attribute_values", jobAttributeValues)
g.Post("/jobs/list", jobList)
opPost(g, "/infra_monitoring/hosts", infraHosts)
opPost(g, "/infra_monitoring/pods", infraPods)
opPost(g, "/infra_monitoring/nodes", infraNodes)
opPost(g, "/infra_monitoring/namespaces", infraNamespaces)
opPost(g, "/infra_monitoring/clusters", infraClusters)
opPost(g, "/infra_monitoring/pvcs", infraVolumes)
opPost(g, "/infra_monitoring/deployments", infraDeployments)
opPost(g, "/infra_monitoring/statefulsets", infraStatefulSets)
opPost(g, "/infra_monitoring/jobs", infraJobs)
opPost(g, "/infra_monitoring/daemonsets", infraDaemonSets)
opGet(g, "/infra_monitoring/checks", infraChecks)
g.Post("/infra_monitoring/hosts", infraHosts)
g.Post("/infra_monitoring/pods", infraPods)
g.Post("/infra_monitoring/nodes", infraNodes)
g.Post("/infra_monitoring/namespaces", infraNamespaces)
g.Post("/infra_monitoring/clusters", infraClusters)
g.Post("/infra_monitoring/pvcs", infraVolumes)
g.Post("/infra_monitoring/deployments", infraDeployments)
g.Post("/infra_monitoring/statefulsets", infraStatefulSets)
g.Post("/infra_monitoring/jobs", infraJobs)
g.Post("/infra_monitoring/daemonsets", infraDaemonSets)
g.Get("/infra_monitoring/checks", infraChecks)
}
// ── hosts ─────────────────────────────────────────────────────────────────────
+30 -30
View File
@@ -59,46 +59,46 @@ import (
// be swallowed by /integrations/:integrationId, the same disambiguation the
// runtime's own mux tree makes.
func mountIntegrations(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// integration catalog and install lifecycle (runtime gate: ViewAccess)
opGet(g, "/integrations", listIntegrations, zip.WithOperationID("ListIntegrations"))
opGet(g, "/integrations/:integrationId", getIntegration, zip.WithOperationID("GetIntegration"))
opGet(g, "/integrations/:integrationId/connection_status", getIntegrationConnectionStatus, zip.WithOperationID("GetIntegrationConnectionStatus"))
opPost(g, "/integrations/install", installIntegration, zip.WithOperationID("InstallIntegration"))
opPost(g, "/integrations/uninstall", uninstallIntegration, zip.WithOperationID("UninstallIntegration"))
g.Get("/integrations", listIntegrations, zip.WithOperationID("ListIntegrations"))
g.Get("/integrations/:integrationId", getIntegration, zip.WithOperationID("GetIntegration"))
g.Get("/integrations/:integrationId/connection_status", getIntegrationConnectionStatus, zip.WithOperationID("GetIntegrationConnectionStatus"))
g.Post("/integrations/install", installIntegration, zip.WithOperationID("InstallIntegration"))
g.Post("/integrations/uninstall", uninstallIntegration, zip.WithOperationID("UninstallIntegration"))
// cloud integrations — accounts and services (runtime gate: AdminAccess)
opGet(g, "/cloud_integrations/:cloud_provider/credentials", getConnectionCredentials, zip.WithOperationID("GetConnectionCredentials"))
opPost(g, "/cloud_integrations/:cloud_provider/accounts", createAccount, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateAccount"))
opGet(g, "/cloud_integrations/:cloud_provider/accounts", listAccounts, zip.WithOperationID("ListAccounts"))
opGet(g, "/cloud_integrations/:cloud_provider/accounts/:id", getAccount, zip.WithOperationID("GetAccount"))
opPut(g, "/cloud_integrations/:cloud_provider/accounts/:id", updateAccount, zip.WithOperationID("UpdateAccount"))
opDelete(g, "/cloud_integrations/:cloud_provider/accounts/:id", disconnectAccount, zip.WithOperationID("DisconnectAccount"))
opGet(g, "/cloud_integrations/:cloud_provider/services", listServicesMetadata, zip.WithOperationID("ListServicesMetadata"))
opGet(g, "/cloud_integrations/:cloud_provider/accounts/:id/services", listAccountServicesMetadata, zip.WithOperationID("ListAccountServicesMetadata"))
opGet(g, "/cloud_integrations/:cloud_provider/services/:service_id", getService, zip.WithOperationID("GetService"))
opPut(g, "/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id", updateService, zip.WithOperationID("UpdateService"))
opGet(g, "/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id", getAccountService, zip.WithOperationID("GetAccountService"))
g.Get("/cloud_integrations/:cloud_provider/credentials", getConnectionCredentials, zip.WithOperationID("GetConnectionCredentials"))
g.Post("/cloud_integrations/:cloud_provider/accounts", createAccount, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateAccount"))
g.Get("/cloud_integrations/:cloud_provider/accounts", listAccounts, zip.WithOperationID("ListAccounts"))
g.Get("/cloud_integrations/:cloud_provider/accounts/:id", getAccount, zip.WithOperationID("GetAccount"))
g.Put("/cloud_integrations/:cloud_provider/accounts/:id", updateAccount, zip.WithOperationID("UpdateAccount"))
g.Delete("/cloud_integrations/:cloud_provider/accounts/:id", disconnectAccount, zip.WithOperationID("DisconnectAccount"))
g.Get("/cloud_integrations/:cloud_provider/services", listServicesMetadata, zip.WithOperationID("ListServicesMetadata"))
g.Get("/cloud_integrations/:cloud_provider/accounts/:id/services", listAccountServicesMetadata, zip.WithOperationID("ListAccountServicesMetadata"))
g.Get("/cloud_integrations/:cloud_provider/services/:service_id", getService, zip.WithOperationID("GetService"))
g.Put("/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id", updateService, zip.WithOperationID("UpdateService"))
g.Get("/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id", getAccountService, zip.WithOperationID("GetAccountService"))
// The deprecated agent check-in keeps its hyphenated path for the agents
// already deployed against it; the check_in path below is its replacement.
opPost(g, "/cloud-integrations/:cloud_provider/agent-check-in", agentCheckInDeprecated, zip.WithOperationID("AgentCheckInDeprecated"))
opPost(g, "/cloud_integrations/:cloud_provider/accounts/check_in", agentCheckIn, zip.WithOperationID("AgentCheckIn"))
g.Post("/cloud-integrations/:cloud_provider/agent-check-in", agentCheckInDeprecated, zip.WithOperationID("AgentCheckInDeprecated"))
g.Post("/cloud_integrations/:cloud_provider/accounts/check_in", agentCheckIn, zip.WithOperationID("AgentCheckIn"))
// gateway ingestion keys and their limits (runtime gate: EditAccess)
opGet(g, "/gateway/ingestion_keys", getIngestionKeys, zip.WithOperationID("GetIngestionKeys"))
opGet(g, "/gateway/ingestion_keys/search", searchIngestionKeys, zip.WithOperationID("SearchIngestionKeys"))
opPost(g, "/gateway/ingestion_keys", createIngestionKey, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateIngestionKey"))
opPatch(g, "/gateway/ingestion_keys/:keyId", updateIngestionKey, zip.WithOperationID("UpdateIngestionKey"))
opDelete(g, "/gateway/ingestion_keys/:keyId", deleteIngestionKey, zip.WithOperationID("DeleteIngestionKey"))
opPost(g, "/gateway/ingestion_keys/:keyId/limits", createIngestionKeyLimit, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateIngestionKeyLimit"))
opPatch(g, "/gateway/ingestion_keys/limits/:limitId", updateIngestionKeyLimit, zip.WithOperationID("UpdateIngestionKeyLimit"))
opDelete(g, "/gateway/ingestion_keys/limits/:limitId", deleteIngestionKeyLimit, zip.WithOperationID("DeleteIngestionKeyLimit"))
g.Get("/gateway/ingestion_keys", getIngestionKeys, zip.WithOperationID("GetIngestionKeys"))
g.Get("/gateway/ingestion_keys/search", searchIngestionKeys, zip.WithOperationID("SearchIngestionKeys"))
g.Post("/gateway/ingestion_keys", createIngestionKey, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateIngestionKey"))
g.Patch("/gateway/ingestion_keys/:keyId", updateIngestionKey, zip.WithOperationID("UpdateIngestionKey"))
g.Delete("/gateway/ingestion_keys/:keyId", deleteIngestionKey, zip.WithOperationID("DeleteIngestionKey"))
g.Post("/gateway/ingestion_keys/:keyId/limits", createIngestionKeyLimit, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateIngestionKeyLimit"))
g.Patch("/gateway/ingestion_keys/limits/:limitId", updateIngestionKeyLimit, zip.WithOperationID("UpdateIngestionKeyLimit"))
g.Delete("/gateway/ingestion_keys/limits/:limitId", deleteIngestionKeyLimit, zip.WithOperationID("DeleteIngestionKeyLimit"))
// Zeus — deployment profile and host (runtime gate: AdminAccess writes, ViewAccess read)
opPut(g, "/zeus/profiles", putProfile, zip.WithOperationID("PutProfile"))
opGet(g, "/zeus/hosts", getHosts, zip.WithOperationID("GetHosts"))
opPut(g, "/zeus/hosts", putHost, zip.WithOperationID("PutHost"))
g.Put("/zeus/profiles", putProfile, zip.WithOperationID("PutProfile"))
g.Get("/zeus/hosts", getHosts, zip.WithOperationID("GetHosts"))
g.Put("/zeus/hosts", putHost, zip.WithOperationID("PutHost"))
}
// ── integration catalog and install lifecycle ────────────────────────────────
+15 -15
View File
@@ -59,30 +59,30 @@ import (
// declarations, so the SDK method, the CLI command and the MCP tool a caller
// already knows keep their names as the routes cross into the document.
func mountLLMObs(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// The four gen_ai span views — projections over spans, not tables.
opGet(g, "/llm/observations", llmObservations, zip.WithOperationID("ListLLMObservations"))
opGet(g, "/llm/traces", llmTraces, zip.WithOperationID("ListLLMTraces"))
opGet(g, "/llm/sessions", llmSessions, zip.WithOperationID("ListLLMSessions"))
opGet(g, "/llm/users", llmUsers, zip.WithOperationID("ListLLMUsers"))
g.Get("/llm/observations", llmObservations, zip.WithOperationID("ListLLMObservations"))
g.Get("/llm/traces", llmTraces, zip.WithOperationID("ListLLMTraces"))
g.Get("/llm/sessions", llmSessions, zip.WithOperationID("ListLLMSessions"))
g.Get("/llm/users", llmUsers, zip.WithOperationID("ListLLMUsers"))
// Eval scores — CRUD over a net-new table.
opGet(g, "/llm/scores", llmListScores, zip.WithOperationID("ListLLMScores"))
opPost(g, "/llm/scores", llmCreateScore, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateLLMScore"))
opGet(g, "/llm/score/:id", llmGetScore, zip.WithOperationID("GetLLMScore"))
opDelete(g, "/llm/score/:id", llmDeleteScore, zip.WithOperationID("DeleteLLMScore"))
g.Get("/llm/scores", llmListScores, zip.WithOperationID("ListLLMScores"))
g.Post("/llm/scores", llmCreateScore, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateLLMScore"))
g.Get("/llm/score/:id", llmGetScore, zip.WithOperationID("GetLLMScore"))
g.Delete("/llm/score/:id", llmDeleteScore, zip.WithOperationID("DeleteLLMScore"))
// Human annotations — a note plus an optional review queue.
opGet(g, "/llm/annotation", llmListAnnotations, zip.WithOperationID("ListLLMAnnotations"))
opPost(g, "/llm/annotation", llmCreateAnnotation, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateLLMAnnotation"))
g.Get("/llm/annotation", llmListAnnotations, zip.WithOperationID("ListLLMAnnotations"))
g.Post("/llm/annotation", llmCreateAnnotation, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateLLMAnnotation"))
// LLM pricing rules — the read, the single bulk write shared by the user and
// the Zeus sync job, and the per-rule read and delete.
opGet(g, "/llm_pricing_rules", llmListPricingRules, zip.WithOperationID("ListLLMPricingRules"))
opPut(g, "/llm_pricing_rules", llmUpsertPricingRules, zip.WithOperationID("CreateOrUpdateLLMPricingRules"))
opGet(g, "/llm_pricing_rules/:id", llmGetPricingRule, zip.WithOperationID("GetLLMPricingRule"))
opDelete(g, "/llm_pricing_rules/:id", llmDeletePricingRule, zip.WithOperationID("DeleteLLMPricingRule"))
g.Get("/llm_pricing_rules", llmListPricingRules, zip.WithOperationID("ListLLMPricingRules"))
g.Put("/llm_pricing_rules", llmUpsertPricingRules, zip.WithOperationID("CreateOrUpdateLLMPricingRules"))
g.Get("/llm_pricing_rules/:id", llmGetPricingRule, zip.WithOperationID("GetLLMPricingRule"))
g.Delete("/llm_pricing_rules/:id", llmDeletePricingRule, zip.WithOperationID("DeleteLLMPricingRule"))
}
// ── the fourteen operations ─────────────────────────────────────────────────────
+10 -10
View File
@@ -45,16 +45,16 @@ import (
// pipelines routes keep the mux tree's discipline — preview registers before
// the parameterised version read so a version can never shadow it.
func mountLogs(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
opGet(g, "/logs", logRecords)
opGet(g, "/logs/fields", logFields)
opPost(g, "/logs/fields", logFieldUpdate)
opGet(g, "/logs/aggregate", logAggregate)
opPost(g, "/logs/pipelines/preview", logPipelinePreview)
opGet(g, "/logs/pipelines/:version", logPipelines)
opPost(g, "/logs/pipelines", logPipelineCreate)
opPost(g, "/logs/promote_paths", logPromote, zip.WithStatus(http.StatusCreated))
opGet(g, "/logs/promote_paths", logPromoted)
g := app.Group(o11yRoot)
g.Get("/logs", logRecords)
g.Get("/logs/fields", logFields)
g.Post("/logs/fields", logFieldUpdate)
g.Get("/logs/aggregate", logAggregate)
g.Post("/logs/pipelines/preview", logPipelinePreview)
g.Get("/logs/pipelines/:version", logPipelines)
g.Post("/logs/pipelines", logPipelineCreate)
g.Post("/logs/promote_paths", logPromote, zip.WithStatus(http.StatusCreated))
g.Get("/logs/promote_paths", logPromoted)
}
// ── the nine operations ───────────────────────────────────────────────────────
+20 -20
View File
@@ -35,28 +35,28 @@ import (
// Collection routes register before the parameterised ones so an id can never
// shadow a collection.
func mountMetrics(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
opGet(g, "/metrics", listMetrics, zip.WithOperationID("ListMetrics"))
opPost(g, "/metrics/stats", metricStats, zip.WithOperationID("GetMetricsStats"))
opPost(g, "/metrics/treemap", metricTreemap, zip.WithOperationID("GetMetricsTreemap"))
opGet(g, "/metrics/attributes", metricAttributes, zip.WithOperationID("GetMetricAttributes"))
opGet(g, "/metrics/metadata", metricMetadata, zip.WithOperationID("GetMetricMetadata"))
opPost(g, "/metrics/metadata", saveMetricMetadata, zip.WithOperationID("UpdateMetricMetadata"))
opGet(g, "/metrics/highlights", metricHighlights, zip.WithOperationID("GetMetricHighlights"))
opGet(g, "/metrics/alerts", metricAlerts, zip.WithOperationID("GetMetricAlerts"))
opGet(g, "/metrics/dashboards", metricDashboards, zip.WithOperationID("GetMetricDashboardsV2"))
opPost(g, "/metrics/inspect", inspectMetric, zip.WithOperationID("InspectMetrics"))
opGet(g, "/metrics/onboarding", metricsOnboarding, zip.WithOperationID("GetMetricsOnboardingStatus"))
g.Get("/metrics", listMetrics, zip.WithOperationID("ListMetrics"))
g.Post("/metrics/stats", metricStats, zip.WithOperationID("GetMetricsStats"))
g.Post("/metrics/treemap", metricTreemap, zip.WithOperationID("GetMetricsTreemap"))
g.Get("/metrics/attributes", metricAttributes, zip.WithOperationID("GetMetricAttributes"))
g.Get("/metrics/metadata", metricMetadata, zip.WithOperationID("GetMetricMetadata"))
g.Post("/metrics/metadata", saveMetricMetadata, zip.WithOperationID("UpdateMetricMetadata"))
g.Get("/metrics/highlights", metricHighlights, zip.WithOperationID("GetMetricHighlights"))
g.Get("/metrics/alerts", metricAlerts, zip.WithOperationID("GetMetricAlerts"))
g.Get("/metrics/dashboards", metricDashboards, zip.WithOperationID("GetMetricDashboardsV2"))
g.Post("/metrics/inspect", inspectMetric, zip.WithOperationID("InspectMetrics"))
g.Get("/metrics/onboarding", metricsOnboarding, zip.WithOperationID("GetMetricsOnboardingStatus"))
opGet(g, "/metric_reduction_rules", listReductionRules, zip.WithOperationID("ListMetricReductionRules"))
opPost(g, "/metric_reduction_rules", createReductionRule, zip.WithOperationID("CreateMetricReductionRule"), zip.WithStatus(http.StatusCreated))
opGet(g, "/metric_reduction_rules/stats", reductionRuleStats, zip.WithOperationID("GetMetricReductionRuleStats"))
opGet(g, "/metric_reduction_rules/timeseries", reductionRuleTimeseries, zip.WithOperationID("GetMetricReductionRuleTimeseries"))
opPost(g, "/metric_reduction_rules/preview", previewReductionRule, zip.WithOperationID("PreviewMetricReductionRule"))
opGet(g, "/metric_reduction_rules/:id", reductionRule, zip.WithOperationID("GetMetricReductionRuleByID"))
opPut(g, "/metric_reduction_rules/:id", saveReductionRule, zip.WithOperationID("UpdateMetricReductionRuleByID"))
opDelete(g, "/metric_reduction_rules/:id", deleteReductionRule, zip.WithOperationID("DeleteMetricReductionRuleByID"))
g.Get("/metric_reduction_rules", listReductionRules, zip.WithOperationID("ListMetricReductionRules"))
g.Post("/metric_reduction_rules", createReductionRule, zip.WithOperationID("CreateMetricReductionRule"), zip.WithStatus(http.StatusCreated))
g.Get("/metric_reduction_rules/stats", reductionRuleStats, zip.WithOperationID("GetMetricReductionRuleStats"))
g.Get("/metric_reduction_rules/timeseries", reductionRuleTimeseries, zip.WithOperationID("GetMetricReductionRuleTimeseries"))
g.Post("/metric_reduction_rules/preview", previewReductionRule, zip.WithOperationID("PreviewMetricReductionRule"))
g.Get("/metric_reduction_rules/:id", reductionRule, zip.WithOperationID("GetMetricReductionRuleByID"))
g.Put("/metric_reduction_rules/:id", saveReductionRule, zip.WithOperationID("UpdateMetricReductionRuleByID"))
g.Delete("/metric_reduction_rules/:id", deleteReductionRule, zip.WithOperationID("DeleteMetricReductionRuleByID"))
}
// ── the metrics-explorer operations ───────────────────────────────────────────
+11 -21
View File
@@ -52,23 +52,11 @@ import (
// 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.
// A HOST MAY TAKE AN ADDRESS. Since every route is named there is no wildcard
// left for a host's own route to shadow, so a host that serves one of these
// addresses itself has to say so with [Claimed] — see claim.go for why that is a
// statement of ownership rather than a filter. Without one, nothing changes and
// the whole table is declared.
func Use(app *zip.App, opts ...Option) error {
c := new(conf)
for _, o := range opts {
o(c)
}
// Read by the declaration verbs below, for this call only: Mount is the
// composition root, so the set is written once here and every declaration it
// makes happens before it returns.
mounting = c
defer func() { mounting = nil }()
app.Logger().Info("o11y: mounting routes", "prefix", o11yRoot, "claimed", len(c.claimed))
// 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).
@@ -216,8 +204,10 @@ func mountHatches(app *zip.App) {
// /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.
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
// 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
}
+3 -3
View File
@@ -13,7 +13,7 @@ import (
// /v1/o11y. Two families:
//
// - INGEST (public, DSN-authenticated in-handler): the Sentry wire endpoints
// POST /v1/o11y/api/{project}/envelope/ and POST /v1/o11y/api/{project}/store/.
// POST /v1/o11y/api/{project}/envelope and POST /v1/o11y/api/{project}/store.
// They are wrapped with OpenAccess (no IAM) because the Sentry SDK presents a DSN
// key, not a Hanzo session; the handler verifies that key. A Sentry DSN of
// https://<key>@<host>/v1/o11y/<org> makes the SDK POST to exactly these paths:
@@ -41,7 +41,7 @@ func (provider *provider) addErrorTrackingRoutes(router routing.Router) {
fn http.HandlerFunc
def handler.OpenAPIDef
}{
{http.MethodPost, "/v1/o11y/api/{project_id}/envelope/", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
{http.MethodPost, "/v1/o11y/api/{project_id}/envelope", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
ID: "IngestErrorEnvelope", Tags: []string{"errortracking"}, Summary: "Ingest a Sentry envelope",
Description: "Sentry-envelope-compatible ingest. Authenticated by the DSN public key (X-Sentry-Auth or ?sentry_key), not a Hanzo session.",
RequestContentType: "application/x-sentry-envelope",
@@ -49,7 +49,7 @@ func (provider *provider) addErrorTrackingRoutes(router routing.Router) {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
SecuritySchemes: []handler.OpenAPISecurityScheme{},
}},
{http.MethodPost, "/v1/o11y/api/{project_id}/store/", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
{http.MethodPost, "/v1/o11y/api/{project_id}/store", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
ID: "IngestErrorStore", Tags: []string{"errortracking"}, Summary: "Ingest a legacy Sentry store event",
Description: "Legacy single-event Sentry ingest. Authenticated by the DSN public key.",
RequestContentType: "application/json",
+3 -3
View File
@@ -22,7 +22,7 @@ import (
// - The FACE, /v1/o11y/sentinel — Hanzo Sentry, the Sentry-parity product read by a
// signed-in person: projects, issues, discover, events, logs, traces, stats. Hanzo
// IAM authz, every one scoped to the caller's org from the validated claims.
// - INGEST, POST /v1/event/{project}/envelope/ and /store/ — a keyed beacon. It is
// - INGEST, POST /v1/event/{project}/envelope and /store — a keyed beacon. It is
// the address a minted DSN spells (implsentry's mintDSN), so the address a client
// is told and the address this router opens are one string. OpenAccess (no IAM):
// a Sentry SDK presents a DSN key, not a Hanzo session; the handler verifies that
@@ -161,7 +161,7 @@ func (provider *provider) addSentryRoutes(router routing.Router) {
fn http.HandlerFunc
def handler.OpenAPIDef
}{
{"/v1/event/{project:guid}/envelope/", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
{"/v1/event/{project:guid}/envelope", provider.authzMiddleware.OpenAccess(h.EnvelopeIngest), handler.OpenAPIDef{
ID: "SentryIngestEnvelope", Tags: []string{"sentry"}, Summary: "Ingest a Sentry envelope",
Description: "Sentry-envelope-compatible ingest. Authenticated by the DSN public key (X-Sentry-Auth or ?sentry_key), not a Hanzo session.",
RequestContentType: "application/x-sentry-envelope",
@@ -169,7 +169,7 @@ func (provider *provider) addSentryRoutes(router routing.Router) {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable},
SecuritySchemes: []handler.OpenAPISecurityScheme{},
}},
{"/v1/event/{project:guid}/store/", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
{"/v1/event/{project:guid}/store", provider.authzMiddleware.OpenAccess(h.StoreIngest), handler.OpenAPIDef{
ID: "SentryIngestStore", Tags: []string{"sentry"}, Summary: "Ingest a legacy Sentry store event",
Description: "Legacy single-event Sentry ingest. Authenticated by the DSN public key.",
RequestContentType: "application/json",
+23 -23
View File
@@ -46,37 +46,37 @@ import (
// routes_licenses.go, then the o11yapiserver singletons). It registers on the
// SAME o11yRoot group logs.go mounts on, ahead of the delegation wildcard.
func mountPlatform(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// routes_misc.go
opGet(g, "/query", promQuery)
opPost(g, "/variables/query", dashboardVars)
opPost(g, "/event", event)
opGet(g, "/usage", usage)
opPost(g, "/dependency_graph", dependencyGraph)
opGet(g, "/version", version)
opGet(g, "/health", health)
opGet(g, "/disks", disks)
opPost(g, "/span_percentile", spanPercentile)
opPost(g, "/query_filter/analyze", analyzeQueryFilter)
opGet(g, "/filter_suggestions", filterSuggestions)
opGet(g, "/infra_onboarding/k8s/status", k8sOnboarding)
opGet(g, "/metric/metric_metadata", legacyMetricMetadata)
g.Get("/query", promQuery)
g.Post("/variables/query", dashboardVars)
g.Post("/event", event)
g.Get("/usage", usage)
g.Post("/dependency_graph", dependencyGraph)
g.Get("/version", version)
g.Get("/health", health)
g.Get("/disks", disks)
g.Post("/span_percentile", spanPercentile)
g.Post("/query_filter/analyze", analyzeQueryFilter)
g.Get("/filter_suggestions", filterSuggestions)
g.Get("/infra_onboarding/k8s/status", k8sOnboarding)
g.Get("/metric/metric_metadata", legacyMetricMetadata)
// routes_settings.go
opPost(g, "/settings/ttl", setRetention)
opGet(g, "/settings/ttl", retention)
opPost(g, "/settings/apdex", setApdex)
opGet(g, "/settings/apdex", apdex)
g.Post("/settings/ttl", setRetention)
g.Get("/settings/ttl", retention)
g.Post("/settings/apdex", setApdex)
g.Get("/settings/apdex", apdex)
// routes_licenses.go
opGet(g, "/licenses", licenses)
opGet(g, "/licenses/active", activateLicense)
g.Get("/licenses", licenses)
g.Get("/licenses/active", activateLicense)
// o11yapiserver: global.go, flagger.go, statsreporter.go
opGet(g, "/global/config", globalConfig)
opGet(g, "/features", features)
opGet(g, "/stats", orgStats)
g.Get("/global/config", globalConfig)
g.Get("/features", features)
g.Get("/stats", orgStats)
}
// ── the operations ────────────────────────────────────────────────────────────
+17 -17
View File
@@ -59,33 +59,33 @@ import (
// The saved-view collection routes register before the parameterised ones so a
// view id can never shadow the collection, exactly as the mux tree ordered them.
func mountQueryCore(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// the v5 composite query engine
opPost(g, "/query_range", querierQueryRange)
opPost(g, "/query_range/preview", querierQueryRangePreview)
opPost(g, "/substitute_vars", querierReplaceVariables)
g.Post("/query_range", querierQueryRange)
g.Post("/query_range/preview", querierQueryRangePreview)
g.Post("/substitute_vars", querierReplaceVariables)
// the legacy metrics range read and the builder-format echo
opGet(g, "/query_range", metricsQueryRange)
opPost(g, "/query_range/format", queryRangeFormat)
g.Get("/query_range", metricsQueryRange)
g.Post("/query_range/format", queryRangeFormat)
// attribute autocomplete for filter building
opGet(g, "/autocomplete/aggregate_attributes", autocompleteAggregate)
opGet(g, "/autocomplete/attribute_keys", autocompleteKeys)
opGet(g, "/autocomplete/attribute_values", autocompleteValues)
opPost(g, "/auto_complete/attribute_values", autocompleteValuesPost)
g.Get("/autocomplete/aggregate_attributes", autocompleteAggregate)
g.Get("/autocomplete/attribute_keys", autocompleteKeys)
g.Get("/autocomplete/attribute_values", autocompleteValues)
g.Post("/auto_complete/attribute_values", autocompleteValuesPost)
// the field catalog
opGet(g, "/fields/keys", fieldKeys)
opGet(g, "/fields/values", fieldValues)
g.Get("/fields/keys", fieldKeys)
g.Get("/fields/values", fieldValues)
// saved explorer views
opGet(g, "/explorer/views", savedViewList)
opPost(g, "/explorer/views", savedViewCreate)
opGet(g, "/explorer/views/:viewId", savedViewGet)
opPut(g, "/explorer/views/:viewId", savedViewUpdate)
opDelete(g, "/explorer/views/:viewId", savedViewDelete)
g.Get("/explorer/views", savedViewList)
g.Post("/explorer/views", savedViewCreate)
g.Get("/explorer/views/:viewId", savedViewGet)
g.Put("/explorer/views/:viewId", savedViewUpdate)
g.Delete("/explorer/views/:viewId", savedViewDelete)
}
// ── the composite query engine ─────────────────────────────────────────────────
+1 -1
View File
@@ -5,7 +5,7 @@ package o11y_test
// It names every function in the package that SYNTHESIZES a request for the
// runtime: one that builds an http.Request and hands it to the runtime
// handler's ServeHTTP. Both halves are required, which is what separates a
// relay from claim.go's hatch — a hatch passes on the request the SERVER
// relay from a hatch (hatch.go) — a hatch passes on the request the SERVER
// already built (whose request-target the server itself populated) and so can
// never carry this defect. A relay builds the request, and a built request is
// only server-shaped if the builder makes it so.
+12 -27
View File
@@ -14,26 +14,10 @@ package o11y
// per value — so a change to the seam had five sites to find, and a change to
// the root had eight.
//
// THE ADDRESS IS NOT A PARAMETER OF THE TRANSPORT. That collapse left one seam
// and one root, and one thing still spelled twice: the address. Every op stated
// it at its registration and then stated it AGAIN, by hand, in the call —
//
// opGet(g, "/traces/:traceId", traceSpans) // once
// relay(ctx, "GET", o11yRoot+"/traces/"+in.TraceID, …) // and again
//
// — 706 spellings of 367 addresses, and the second spelling is the one that got
// requested, so a route pattern that stopped agreeing with it was never
// consulted and never contradicted. That is not hypothetical: three of these
// addresses name their segment traceId while the runtime registered traceID, and
// nothing in the process could tell, because a router matches by POSITION and a
// parameter's name is invisible to it.
//
// So the address is stated once, at the registration, and travels with the call
// (see claim.go's addressed). relay takes no method and no path: it reads the
// address the request arrived at, which is a fact about the call and
// not a decision this function makes. The 119 hand-built paths are gone with it —
// zip.Address renders the concrete one from the same input zip bound the segments
// into, so the round trip is exact by construction.
// The address is not a parameter of the transport. relay takes no method and no
// path: zip.OpOf is the operation being served and zip.AddressOf is the concrete
// path it was called at, read back out of the input zip bound the matched
// segments into.
//
// What relay is: it hands a typed op's call to the handler the runtime serves AT
// THAT ADDRESS — the same handler the runtime's own router would dispatch to, so
@@ -104,22 +88,23 @@ const (
// and decodes the answer into the op's Out. Pass out == nil for the operations
// whose answer is a 204 with no body.
//
// The address comes from the context, not from the caller — see the file comment
// and claim.go's addressed.
// The address comes from the call, not from the caller (see the file comment).
func relay(ctx context.Context, params url.Values, body, out any) error {
a, known := addressOf(ctx)
if !known {
op, served := zip.OpOf(ctx)
target, known := zip.AddressOf(ctx)
if !served || !known {
// Only reachable by calling an op's function directly rather than through
// the declaration that registered it, which is a programming error and not
// a request that can arrive.
return zip.ErrInternal("o11y: this operation was invoked outside its own registration, so it has no address to relay to")
}
h, err := at(a.method, a.template)
// The pattern resolves the handler, in the public spelling both sides state an
// address in; the concrete path is what the request carries.
h, err := at(op.Method, zip.Template(op.Path))
if err != nil {
return err
}
target := a.path
if q := params.Encode(); q != "" {
target += "?" + q
}
@@ -132,7 +117,7 @@ func relay(ctx context.Context, params url.Values, body, out any) error {
}
payload = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, a.method, target, payload)
req, err := http.NewRequestWithContext(ctx, op.Method, target, payload)
if err != nil {
return zip.ErrBadRequest(err.Error())
}
+43
View File
@@ -3,6 +3,8 @@ package o11y_test
import (
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
@@ -109,6 +111,47 @@ func TestEveryRouteIsNamedAndCounted(t *testing.T) {
}
}
// The table, address by address. The count above says how many routes there
// are; testdata/routes.txt says which, so a route that moves, disappears or
// changes its spelling reads as a diff instead of as a count that still adds up.
func TestRouteTableIsTheCensus(t *testing.T) {
census, err := os.ReadFile("testdata/routes.txt")
if err != nil {
t.Fatalf("read the census: %v", err)
}
var got []string
for route := range registered(t, mounted(t)) {
got = append(got, route)
}
sort.Strings(got)
if moved := drift(strings.Split(strings.TrimSpace(string(census)), "\n"), got); moved != "" {
t.Errorf("the route table is not the one in testdata/routes.txt:\n%s", moved)
}
}
// drift names what the census holds and the mount does not, and the reverse.
func drift(want, got []string) string {
held, have := map[string]bool{}, map[string]bool{}
for _, w := range want {
held[w] = true
}
for _, g := range got {
have[g] = true
}
var out []string
for _, w := range want {
if !have[w] {
out = append(out, " gone: "+w)
}
}
for _, g := range got {
if !held[g] {
out = append(out, " new: "+g)
}
}
return strings.Join(out, "\n")
}
// No route may end in a wildcard. This is the invariant the whole change buys:
// a NEW un-typed route is a 404 someone has to come and justify, not a silent
// fall-through into the runtime.
+37 -37
View File
@@ -60,57 +60,57 @@ import (
// safely: the router matches the most specific pattern, so /rules/test is never
// swallowed by /rules/:id, the same disambiguation the runtime's own tree makes.
func mountRulesAlerts(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
// alert rules (runtime gate per op: see each op's comment)
opGet(g, "/rules", listRules, zip.WithOperationID("ListRules"))
opGet(g, "/rules/:id", getRuleByID, zip.WithOperationID("GetRuleByID"))
opPost(g, "/rules", createRule, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateRule"))
opPut(g, "/rules/:id", updateRuleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateRuleByID"))
opDelete(g, "/rules/:id", deleteRuleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteRuleByID"))
opPatch(g, "/rules/:id", patchRuleByID, zip.WithOperationID("PatchRuleByID"))
opPost(g, "/rules/test", testRule, zip.WithOperationID("TestRule"))
g.Get("/rules", listRules, zip.WithOperationID("ListRules"))
g.Get("/rules/:id", getRuleByID, zip.WithOperationID("GetRuleByID"))
g.Post("/rules", createRule, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateRule"))
g.Put("/rules/:id", updateRuleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateRuleByID"))
g.Delete("/rules/:id", deleteRuleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteRuleByID"))
g.Patch("/rules/:id", patchRuleByID, zip.WithOperationID("PatchRuleByID"))
g.Post("/rules/test", testRule, zip.WithOperationID("TestRule"))
// planned maintenance / downtime schedules
opGet(g, "/downtime_schedules", listDowntimeSchedules, zip.WithOperationID("ListDowntimeSchedules"))
opGet(g, "/downtime_schedules/:id", getDowntimeScheduleByID, zip.WithOperationID("GetDowntimeScheduleByID"))
opPost(g, "/downtime_schedules", createDowntimeSchedule, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDowntimeSchedule"))
opPut(g, "/downtime_schedules/:id", updateDowntimeScheduleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateDowntimeScheduleByID"))
opDelete(g, "/downtime_schedules/:id", deleteDowntimeScheduleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteDowntimeScheduleByID"))
g.Get("/downtime_schedules", listDowntimeSchedules, zip.WithOperationID("ListDowntimeSchedules"))
g.Get("/downtime_schedules/:id", getDowntimeScheduleByID, zip.WithOperationID("GetDowntimeScheduleByID"))
g.Post("/downtime_schedules", createDowntimeSchedule, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateDowntimeSchedule"))
g.Put("/downtime_schedules/:id", updateDowntimeScheduleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateDowntimeScheduleByID"))
g.Delete("/downtime_schedules/:id", deleteDowntimeScheduleByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteDowntimeScheduleByID"))
// rule state history — v2 reads (GET, URL query parameters)
opGet(g, "/rules/:id/history/stats", getRuleHistoryStats, zip.WithOperationID("GetRuleHistoryStats"))
opGet(g, "/rules/:id/history/timeline", getRuleHistoryTimeline, zip.WithOperationID("GetRuleHistoryTimeline"))
opGet(g, "/rules/:id/history/top_contributors", getRuleHistoryTopContributors, zip.WithOperationID("GetRuleHistoryTopContributors"))
opGet(g, "/rules/:id/history/filter_keys", getRuleHistoryFilterKeys, zip.WithOperationID("GetRuleHistoryFilterKeys"))
opGet(g, "/rules/:id/history/filter_values", getRuleHistoryFilterValues, zip.WithOperationID("GetRuleHistoryFilterValues"))
opGet(g, "/rules/:id/history/overall_status", getRuleHistoryOverallStatus, zip.WithOperationID("GetRuleHistoryOverallStatus"))
g.Get("/rules/:id/history/stats", getRuleHistoryStats, zip.WithOperationID("GetRuleHistoryStats"))
g.Get("/rules/:id/history/timeline", getRuleHistoryTimeline, zip.WithOperationID("GetRuleHistoryTimeline"))
g.Get("/rules/:id/history/top_contributors", getRuleHistoryTopContributors, zip.WithOperationID("GetRuleHistoryTopContributors"))
g.Get("/rules/:id/history/filter_keys", getRuleHistoryFilterKeys, zip.WithOperationID("GetRuleHistoryFilterKeys"))
g.Get("/rules/:id/history/filter_values", getRuleHistoryFilterValues, zip.WithOperationID("GetRuleHistoryFilterValues"))
g.Get("/rules/:id/history/overall_status", getRuleHistoryOverallStatus, zip.WithOperationID("GetRuleHistoryOverallStatus"))
// rule state history — v1 reads (POST, JSON body) and the legacy test-fire
opPost(g, "/testRule", testRuleNotification, zip.WithOperationID("TestRuleNotification"))
opPost(g, "/rules/:id/history/stats", getRuleStats, zip.WithOperationID("GetRuleStats"))
opPost(g, "/rules/:id/history/timeline", getRuleStateHistory, zip.WithOperationID("GetRuleStateHistory"))
opPost(g, "/rules/:id/history/top_contributors", getRuleStateHistoryTopContributors, zip.WithOperationID("GetRuleStateHistoryTopContributors"))
opPost(g, "/rules/:id/history/overall_status", getOverallStateTransitions, zip.WithOperationID("GetOverallStateTransitions"))
g.Post("/testRule", testRuleNotification, zip.WithOperationID("TestRuleNotification"))
g.Post("/rules/:id/history/stats", getRuleStats, zip.WithOperationID("GetRuleStats"))
g.Post("/rules/:id/history/timeline", getRuleStateHistory, zip.WithOperationID("GetRuleStateHistory"))
g.Post("/rules/:id/history/top_contributors", getRuleStateHistoryTopContributors, zip.WithOperationID("GetRuleStateHistoryTopContributors"))
g.Post("/rules/:id/history/overall_status", getOverallStateTransitions, zip.WithOperationID("GetOverallStateTransitions"))
// notification channels
opGet(g, "/channels", listChannels, zip.WithOperationID("ListChannels"))
opGet(g, "/channels/:id", getChannelByID, zip.WithOperationID("GetChannelByID"))
opPost(g, "/channels", createChannel, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateChannel"))
opPut(g, "/channels/:id", updateChannelByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateChannelByID"))
opDelete(g, "/channels/:id", deleteChannelByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteChannelByID"))
opPost(g, "/channels/test", testChannel, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("TestChannel"))
opPost(g, "/testChannel", testChannelDeprecated, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("TestChannelDeprecated"))
g.Get("/channels", listChannels, zip.WithOperationID("ListChannels"))
g.Get("/channels/:id", getChannelByID, zip.WithOperationID("GetChannelByID"))
g.Post("/channels", createChannel, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateChannel"))
g.Put("/channels/:id", updateChannelByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("UpdateChannelByID"))
g.Delete("/channels/:id", deleteChannelByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteChannelByID"))
g.Post("/channels/test", testChannel, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("TestChannel"))
g.Post("/testChannel", testChannelDeprecated, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("TestChannelDeprecated"))
// route policies
opGet(g, "/route_policies", getAllRoutePolicies, zip.WithOperationID("GetAllRoutePolicies"))
opGet(g, "/route_policies/:id", getRoutePolicyByID, zip.WithOperationID("GetRoutePolicyByID"))
opPost(g, "/route_policies", createRoutePolicy, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateRoutePolicy"))
opPut(g, "/route_policies/:id", updateRoutePolicy, zip.WithOperationID("UpdateRoutePolicy"))
opDelete(g, "/route_policies/:id", deleteRoutePolicyByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteRoutePolicyByID"))
g.Get("/route_policies", getAllRoutePolicies, zip.WithOperationID("GetAllRoutePolicies"))
g.Get("/route_policies/:id", getRoutePolicyByID, zip.WithOperationID("GetRoutePolicyByID"))
g.Post("/route_policies", createRoutePolicy, zip.WithStatus(http.StatusCreated), zip.WithOperationID("CreateRoutePolicy"))
g.Put("/route_policies/:id", updateRoutePolicy, zip.WithOperationID("UpdateRoutePolicy"))
g.Delete("/route_policies/:id", deleteRoutePolicyByID, zip.WithStatus(http.StatusNoContent), zip.WithOperationID("DeleteRoutePolicyByID"))
// alerts
opGet(g, "/alerts", getAlerts, zip.WithOperationID("GetAlerts"))
g.Get("/alerts", getAlerts, zip.WithOperationID("GetAlerts"))
}
// ── alert rules ─────────────────────────────────────────────────────────────
+22 -22
View File
@@ -33,8 +33,8 @@ package o11y
// - Legacy exceptions (listErrors, countErrors, errorFromErrorID,
// errorFromGroupID, nextPrevErrorIDs): ViewAccess, every one.
//
// The FOUR ingest routes — POST /v1/event/{project}/envelope|store/ and POST
// /v1/o11y/api/{project}/envelope|store/ — are the deliberate escape hatches,
// The FOUR ingest routes — POST /v1/event/{project}/envelope|store and POST
// /v1/o11y/api/{project}/envelope|store — are the deliberate escape hatches,
// NOT typed here and not on this face at all: ingest is a keyed beacon and a
// face is read by a person, so ingest has its own root (relay.go's eventRoot,
// the address a minted DSN spells). They are OpenAccess (a Sentry SDK presents a DSN key,
@@ -61,27 +61,27 @@ import (
// the o11yRoot group. Both roots and the seam they relay through are spelled
// once each, in relay.go.
func mountSentryErrors(app *zip.App) {
gsentry := rooted{app.Group(sentinelRoot), sentinelRoot}
opGet(gsentry, "/projects", sentryListProjects)
opPost(gsentry, "/projects", sentryCreateProject)
opGet(gsentry, "/projects/:id", sentryGetProject)
opDelete(gsentry, "/projects/:id", sentryDeleteProject)
opPost(gsentry, "/projects/:id/keys/rotate", sentryRotateProjectKey)
opGet(gsentry, "/issues", sentryListIssues)
opGet(gsentry, "/issues/:id", sentryGetIssue)
opPut(gsentry, "/issues/:id", sentryUpdateIssue)
opGet(gsentry, "/issues/:id/events", sentryIssueEvents)
opGet(gsentry, "/events/:id", sentryGetEvent)
gsentry := app.Group(sentinelRoot)
gsentry.Get("/projects", sentryListProjects)
gsentry.Post("/projects", sentryCreateProject)
gsentry.Get("/projects/:id", sentryGetProject)
gsentry.Delete("/projects/:id", sentryDeleteProject)
gsentry.Post("/projects/:id/keys/rotate", sentryRotateProjectKey)
gsentry.Get("/issues", sentryListIssues)
gsentry.Get("/issues/:id", sentryGetIssue)
gsentry.Put("/issues/:id", sentryUpdateIssue)
gsentry.Get("/issues/:id/events", sentryIssueEvents)
gsentry.Get("/events/:id", sentryGetEvent)
go11y := rooted{app.Group(o11yRoot), o11yRoot}
opGet(go11y, "/errortracking/issues", errorListIssues)
opGet(go11y, "/errortracking/issues/:id", errorGetIssue)
opPost(go11y, "/errortracking/issues/:id", errorUpdateIssue)
opPost(go11y, "/listErrors", errorsList)
opPost(go11y, "/countErrors", errorsCount)
opGet(go11y, "/errorFromErrorID", errorFromErrorID)
opGet(go11y, "/errorFromGroupID", errorFromGroupID)
opGet(go11y, "/nextPrevErrorIDs", nextPrevErrorIDs)
go11y := app.Group(o11yRoot)
go11y.Get("/errortracking/issues", errorListIssues)
go11y.Get("/errortracking/issues/:id", errorGetIssue)
go11y.Post("/errortracking/issues/:id", errorUpdateIssue)
go11y.Post("/listErrors", errorsList)
go11y.Post("/countErrors", errorsCount)
go11y.Get("/errorFromErrorID", errorFromErrorID)
go11y.Get("/errorFromGroupID", errorFromGroupID)
go11y.Get("/nextPrevErrorIDs", nextPrevErrorIDs)
}
// ── sentry projects ─────────────────────────────────────────────────────────
+9 -9
View File
@@ -38,17 +38,17 @@ func mountSpanMappers(app *zip.App) {
// with the collection as its prefix would register the collection routes at
// the empty sub-path, which lands them on a TRAILING SLASH the runtime does
// not serve. The collection is a path, not a prefix.
g := rooted{app.Group(o11yRoot), o11yRoot}
g := app.Group(o11yRoot)
opGet(g, "/span_mapper_groups", spanMapperGroups, op("ListSpanMapperGroups"))
opPost(g, "/span_mapper_groups", spanMapperGroupCreate, op("CreateSpanMapperGroup"), zip.WithStatus(http.StatusCreated))
opPatch(g, "/span_mapper_groups/:groupId", spanMapperGroupUpdate, op("UpdateSpanMapperGroup"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/span_mapper_groups/:groupId", spanMapperGroupDelete, op("DeleteSpanMapperGroup"), zip.WithStatus(http.StatusNoContent))
g.Get("/span_mapper_groups", spanMapperGroups, op("ListSpanMapperGroups"))
g.Post("/span_mapper_groups", spanMapperGroupCreate, op("CreateSpanMapperGroup"), zip.WithStatus(http.StatusCreated))
g.Patch("/span_mapper_groups/:groupId", spanMapperGroupUpdate, op("UpdateSpanMapperGroup"), zip.WithStatus(http.StatusNoContent))
g.Delete("/span_mapper_groups/:groupId", spanMapperGroupDelete, op("DeleteSpanMapperGroup"), zip.WithStatus(http.StatusNoContent))
opGet(g, "/span_mapper_groups/:groupId/span_mappers", spanMappers, op("ListSpanMappers"))
opPost(g, "/span_mapper_groups/:groupId/span_mappers", spanMapperCreate, op("CreateSpanMapper"), zip.WithStatus(http.StatusCreated))
opPatch(g, "/span_mapper_groups/:groupId/span_mappers/:mapperId", spanMapperUpdate, op("UpdateSpanMapper"), zip.WithStatus(http.StatusNoContent))
opDelete(g, "/span_mapper_groups/:groupId/span_mappers/:mapperId", spanMapperDelete, op("DeleteSpanMapper"), zip.WithStatus(http.StatusNoContent))
g.Get("/span_mapper_groups/:groupId/span_mappers", spanMappers, op("ListSpanMappers"))
g.Post("/span_mapper_groups/:groupId/span_mappers", spanMapperCreate, op("CreateSpanMapper"), zip.WithStatus(http.StatusCreated))
g.Patch("/span_mapper_groups/:groupId/span_mappers/:mapperId", spanMapperUpdate, op("UpdateSpanMapper"), zip.WithStatus(http.StatusNoContent))
g.Delete("/span_mapper_groups/:groupId/span_mappers/:mapperId", spanMapperDelete, op("DeleteSpanMapper"), zip.WithStatus(http.StatusNoContent))
}
// ── groups ────────────────────────────────────────────────────────────────────
+6 -6
View File
@@ -36,12 +36,12 @@ import (
// Collection routes register before the parameterised one so an id can never
// shadow a collection.
func mountTelemetry(app *zip.App) {
g := rooted{app.Group(sentinelRoot), sentinelRoot}
opPost(g, "/discover", discover)
opGet(g, "/logs", logs)
opGet(g, "/traces", traces)
opGet(g, "/traces/:id", trace)
opGet(g, "/stats", stats)
g := app.Group(sentinelRoot)
g.Post("/discover", discover)
g.Get("/logs", logs)
g.Get("/traces", traces)
g.Get("/traces/:id", trace)
g.Get("/stats", stats)
}
// ── the five operations ───────────────────────────────────────────────────────
+329
View File
@@ -0,0 +1,329 @@
DELETE /v1/o11y/channels/:id
DELETE /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id
DELETE /v1/o11y/dashboard_views/:id
DELETE /v1/o11y/dashboards/:id
DELETE /v1/o11y/dashboards/:id/lock
DELETE /v1/o11y/dashboards/:id/public
DELETE /v1/o11y/downtime_schedules/:id
DELETE /v1/o11y/explorer/views/:viewId
DELETE /v1/o11y/gateway/ingestion_keys/:keyId
DELETE /v1/o11y/gateway/ingestion_keys/limits/:limitId
DELETE /v1/o11y/llm/score/:id
DELETE /v1/o11y/llm_pricing_rules/:id
DELETE /v1/o11y/metric_reduction_rules/:id
DELETE /v1/o11y/roles/:id
DELETE /v1/o11y/route_policies/:id
DELETE /v1/o11y/rules/:id
DELETE /v1/o11y/sentinel/projects/:id
DELETE /v1/o11y/service_accounts/:id
DELETE /v1/o11y/service_accounts/:id/keys/:fid
DELETE /v1/o11y/service_accounts/:id/roles/:rid
DELETE /v1/o11y/span_mapper_groups/:groupId
DELETE /v1/o11y/span_mapper_groups/:groupId/span_mappers/:mapperId
DELETE /v1/o11y/trace-funnels/:funnel_id
DELETE /v1/o11y/users/me/dashboards/:id/pins
GET /v1/o11y/alerts
GET /v1/o11y/autocomplete/aggregate_attributes
GET /v1/o11y/autocomplete/attribute_keys
GET /v1/o11y/autocomplete/attribute_values
GET /v1/o11y/channels
GET /v1/o11y/channels/:id
GET /v1/o11y/cloud_integrations/:cloud_provider/accounts
GET /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id
GET /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id/services
GET /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id
GET /v1/o11y/cloud_integrations/:cloud_provider/credentials
GET /v1/o11y/cloud_integrations/:cloud_provider/services
GET /v1/o11y/cloud_integrations/:cloud_provider/services/:service_id
GET /v1/o11y/clusters/attribute_keys
GET /v1/o11y/clusters/attribute_values
GET /v1/o11y/daemonsets/attribute_keys
GET /v1/o11y/daemonsets/attribute_values
GET /v1/o11y/dashboard_views
GET /v1/o11y/dashboards
GET /v1/o11y/dashboards/:id
GET /v1/o11y/dashboards/:id/public
GET /v1/o11y/deployments/attribute_keys
GET /v1/o11y/deployments/attribute_values
GET /v1/o11y/disks
GET /v1/o11y/downtime_schedules
GET /v1/o11y/downtime_schedules/:id
GET /v1/o11y/errorFromErrorID
GET /v1/o11y/errorFromGroupID
GET /v1/o11y/errortracking/issues
GET /v1/o11y/errortracking/issues/:id
GET /v1/o11y/explorer/views
GET /v1/o11y/explorer/views/:viewId
GET /v1/o11y/features
GET /v1/o11y/fields/keys
GET /v1/o11y/fields/values
GET /v1/o11y/filter_suggestions
GET /v1/o11y/gateway/ingestion_keys
GET /v1/o11y/gateway/ingestion_keys/search
GET /v1/o11y/global/config
GET /v1/o11y/health
GET /v1/o11y/healthz
GET /v1/o11y/hosts/attribute_keys
GET /v1/o11y/hosts/attribute_values
GET /v1/o11y/infra_monitoring/checks
GET /v1/o11y/infra_onboarding/k8s/status
GET /v1/o11y/integrations
GET /v1/o11y/integrations/:integrationId
GET /v1/o11y/integrations/:integrationId/connection_status
GET /v1/o11y/jobs/attribute_keys
GET /v1/o11y/jobs/attribute_values
GET /v1/o11y/licenses
GET /v1/o11y/licenses/active
GET /v1/o11y/livez
GET /v1/o11y/llm/annotation
GET /v1/o11y/llm/observations
GET /v1/o11y/llm/score/:id
GET /v1/o11y/llm/scores
GET /v1/o11y/llm/sessions
GET /v1/o11y/llm/traces
GET /v1/o11y/llm/users
GET /v1/o11y/llm_pricing_rules
GET /v1/o11y/llm_pricing_rules/:id
GET /v1/o11y/logs
GET /v1/o11y/logs/aggregate
GET /v1/o11y/logs/fields
GET /v1/o11y/logs/livetail
GET /v1/o11y/logs/pipelines/:version
GET /v1/o11y/logs/promote_paths
GET /v1/o11y/metric/metric_metadata
GET /v1/o11y/metric_reduction_rules
GET /v1/o11y/metric_reduction_rules/:id
GET /v1/o11y/metric_reduction_rules/stats
GET /v1/o11y/metric_reduction_rules/timeseries
GET /v1/o11y/metrics
GET /v1/o11y/metrics/alerts
GET /v1/o11y/metrics/attributes
GET /v1/o11y/metrics/dashboards
GET /v1/o11y/metrics/highlights
GET /v1/o11y/metrics/metadata
GET /v1/o11y/metrics/onboarding
GET /v1/o11y/namespaces/attribute_keys
GET /v1/o11y/namespaces/attribute_values
GET /v1/o11y/nextPrevErrorIDs
GET /v1/o11y/nodes/attribute_keys
GET /v1/o11y/nodes/attribute_values
GET /v1/o11y/org/preferences
GET /v1/o11y/org/preferences/:name
GET /v1/o11y/orgs/me
GET /v1/o11y/orgs/me/filters
GET /v1/o11y/orgs/me/filters/:signal
GET /v1/o11y/pods/attribute_keys
GET /v1/o11y/pods/attribute_values
GET /v1/o11y/processes/attribute_keys
GET /v1/o11y/processes/attribute_values
GET /v1/o11y/public/dashboards/:id
GET /v1/o11y/public/dashboards/:id/widgets/:idx/query_range
GET /v1/o11y/pvcs/attribute_keys
GET /v1/o11y/pvcs/attribute_values
GET /v1/o11y/query
GET /v1/o11y/query_progress
GET /v1/o11y/query_range
GET /v1/o11y/readyz
GET /v1/o11y/roles
GET /v1/o11y/roles/:id
GET /v1/o11y/route_policies
GET /v1/o11y/route_policies/:id
GET /v1/o11y/rules
GET /v1/o11y/rules/:id
GET /v1/o11y/rules/:id/history/filter_keys
GET /v1/o11y/rules/:id/history/filter_values
GET /v1/o11y/rules/:id/history/overall_status
GET /v1/o11y/rules/:id/history/stats
GET /v1/o11y/rules/:id/history/timeline
GET /v1/o11y/rules/:id/history/top_contributors
GET /v1/o11y/sentinel/events/:id
GET /v1/o11y/sentinel/issues
GET /v1/o11y/sentinel/issues/:id
GET /v1/o11y/sentinel/issues/:id/events
GET /v1/o11y/sentinel/logs
GET /v1/o11y/sentinel/projects
GET /v1/o11y/sentinel/projects/:id
GET /v1/o11y/sentinel/stats
GET /v1/o11y/sentinel/traces
GET /v1/o11y/sentinel/traces/:id
GET /v1/o11y/service_accounts
GET /v1/o11y/service_accounts/:id
GET /v1/o11y/service_accounts/:id/keys
GET /v1/o11y/service_accounts/:id/roles
GET /v1/o11y/service_accounts/me
GET /v1/o11y/services/list
GET /v1/o11y/settings/apdex
GET /v1/o11y/settings/ttl
GET /v1/o11y/span_mapper_groups
GET /v1/o11y/span_mapper_groups/:groupId/span_mappers
GET /v1/o11y/statefulsets/attribute_keys
GET /v1/o11y/statefulsets/attribute_values
GET /v1/o11y/stats
GET /v1/o11y/trace-funnels/:funnel_id
GET /v1/o11y/trace-funnels/list
GET /v1/o11y/traces/:traceId
GET /v1/o11y/traces/fields
GET /v1/o11y/usage
GET /v1/o11y/user/preferences
GET /v1/o11y/user/preferences/:name
GET /v1/o11y/users/me
GET /v1/o11y/users/me/dashboards
GET /v1/o11y/version
GET /v1/o11y/zeus/hosts
PATCH /v1/o11y/dashboards/:id
PATCH /v1/o11y/gateway/ingestion_keys/:keyId
PATCH /v1/o11y/gateway/ingestion_keys/limits/:limitId
PATCH /v1/o11y/rules/:id
PATCH /v1/o11y/span_mapper_groups/:groupId
PATCH /v1/o11y/span_mapper_groups/:groupId/span_mappers/:mapperId
POST /v1/event/:project/envelope
POST /v1/event/:project/store
POST /v1/o11y/api/:project_id/envelope
POST /v1/o11y/api/:project_id/store
POST /v1/o11y/authz/check
POST /v1/o11y/auto_complete/attribute_values
POST /v1/o11y/channels
POST /v1/o11y/channels/test
POST /v1/o11y/cloud-integrations/:cloud_provider/agent-check-in
POST /v1/o11y/cloud_integrations/:cloud_provider/accounts
POST /v1/o11y/cloud_integrations/:cloud_provider/accounts/check_in
POST /v1/o11y/clusters/list
POST /v1/o11y/countErrors
POST /v1/o11y/daemonsets/list
POST /v1/o11y/dashboard_views
POST /v1/o11y/dashboards
POST /v1/o11y/dashboards/:id/clone
POST /v1/o11y/dashboards/:id/public
POST /v1/o11y/dependency_graph
POST /v1/o11y/deployments/list
POST /v1/o11y/downtime_schedules
POST /v1/o11y/errortracking/issues/:id
POST /v1/o11y/event
POST /v1/o11y/explorer/views
POST /v1/o11y/export_raw_data
POST /v1/o11y/gateway/ingestion_keys
POST /v1/o11y/gateway/ingestion_keys/:keyId/limits
POST /v1/o11y/hosts/list
POST /v1/o11y/infra_monitoring/clusters
POST /v1/o11y/infra_monitoring/daemonsets
POST /v1/o11y/infra_monitoring/deployments
POST /v1/o11y/infra_monitoring/hosts
POST /v1/o11y/infra_monitoring/jobs
POST /v1/o11y/infra_monitoring/namespaces
POST /v1/o11y/infra_monitoring/nodes
POST /v1/o11y/infra_monitoring/pods
POST /v1/o11y/infra_monitoring/pvcs
POST /v1/o11y/infra_monitoring/statefulsets
POST /v1/o11y/integrations/install
POST /v1/o11y/integrations/uninstall
POST /v1/o11y/jobs/list
POST /v1/o11y/listErrors
POST /v1/o11y/llm/annotation
POST /v1/o11y/llm/scores
POST /v1/o11y/logs/fields
POST /v1/o11y/logs/pipelines
POST /v1/o11y/logs/pipelines/preview
POST /v1/o11y/logs/promote_paths
POST /v1/o11y/messaging-queues/kafka/consumer-lag/consumer-details
POST /v1/o11y/messaging-queues/kafka/consumer-lag/network-latency
POST /v1/o11y/messaging-queues/kafka/consumer-lag/producer-details
POST /v1/o11y/messaging-queues/kafka/onboarding/consumers
POST /v1/o11y/messaging-queues/kafka/onboarding/kafka
POST /v1/o11y/messaging-queues/kafka/onboarding/producers
POST /v1/o11y/messaging-queues/kafka/partition-latency/consumer
POST /v1/o11y/messaging-queues/kafka/partition-latency/overview
POST /v1/o11y/messaging-queues/kafka/span/evaluation
POST /v1/o11y/messaging-queues/kafka/topic-throughput/consumer
POST /v1/o11y/messaging-queues/kafka/topic-throughput/consumer-details
POST /v1/o11y/messaging-queues/kafka/topic-throughput/producer
POST /v1/o11y/messaging-queues/kafka/topic-throughput/producer-details
POST /v1/o11y/messaging-queues/queue-overview
POST /v1/o11y/metric_reduction_rules
POST /v1/o11y/metric_reduction_rules/preview
POST /v1/o11y/metrics/inspect
POST /v1/o11y/metrics/metadata
POST /v1/o11y/metrics/stats
POST /v1/o11y/metrics/treemap
POST /v1/o11y/namespaces/list
POST /v1/o11y/nodes/list
POST /v1/o11y/pods/list
POST /v1/o11y/processes/list
POST /v1/o11y/pvcs/list
POST /v1/o11y/query_filter/analyze
POST /v1/o11y/query_range
POST /v1/o11y/query_range/format
POST /v1/o11y/query_range/preview
POST /v1/o11y/roles
POST /v1/o11y/route_policies
POST /v1/o11y/rules
POST /v1/o11y/rules/:id/history/overall_status
POST /v1/o11y/rules/:id/history/stats
POST /v1/o11y/rules/:id/history/timeline
POST /v1/o11y/rules/:id/history/top_contributors
POST /v1/o11y/rules/test
POST /v1/o11y/sentinel/discover
POST /v1/o11y/sentinel/projects
POST /v1/o11y/sentinel/projects/:id/keys/rotate
POST /v1/o11y/service/entry_point_operations
POST /v1/o11y/service/top_level_operations
POST /v1/o11y/service/top_operations
POST /v1/o11y/service_accounts
POST /v1/o11y/service_accounts/:id/keys
POST /v1/o11y/service_accounts/:id/roles
POST /v1/o11y/services
POST /v1/o11y/settings/apdex
POST /v1/o11y/settings/ttl
POST /v1/o11y/span_mapper_groups
POST /v1/o11y/span_mapper_groups/:groupId/span_mappers
POST /v1/o11y/span_percentile
POST /v1/o11y/statefulsets/list
POST /v1/o11y/substitute_vars
POST /v1/o11y/testChannel
POST /v1/o11y/testRule
POST /v1/o11y/third-party-apis/overview/domain
POST /v1/o11y/third-party-apis/overview/list
POST /v1/o11y/trace-funnels/:funnel_id/analytics/error-traces
POST /v1/o11y/trace-funnels/:funnel_id/analytics/overview
POST /v1/o11y/trace-funnels/:funnel_id/analytics/slow-traces
POST /v1/o11y/trace-funnels/:funnel_id/analytics/steps
POST /v1/o11y/trace-funnels/:funnel_id/analytics/steps/overview
POST /v1/o11y/trace-funnels/:funnel_id/analytics/validate
POST /v1/o11y/trace-funnels/analytics/error-traces
POST /v1/o11y/trace-funnels/analytics/overview
POST /v1/o11y/trace-funnels/analytics/slow-traces
POST /v1/o11y/trace-funnels/analytics/steps
POST /v1/o11y/trace-funnels/analytics/steps/overview
POST /v1/o11y/trace-funnels/analytics/validate
POST /v1/o11y/trace-funnels/new
POST /v1/o11y/traces/:traceId/aggregations
POST /v1/o11y/traces/:traceId/flamegraph
POST /v1/o11y/traces/:traceId/waterfall
POST /v1/o11y/traces/fields
POST /v1/o11y/variables/query
PUT /v1/o11y/channels/:id
PUT /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id
PUT /v1/o11y/cloud_integrations/:cloud_provider/accounts/:id/services/:service_id
PUT /v1/o11y/dashboard_views/:id
PUT /v1/o11y/dashboards/:id
PUT /v1/o11y/dashboards/:id/lock
PUT /v1/o11y/dashboards/:id/public
PUT /v1/o11y/downtime_schedules/:id
PUT /v1/o11y/explorer/views/:viewId
PUT /v1/o11y/llm_pricing_rules
PUT /v1/o11y/metric_reduction_rules/:id
PUT /v1/o11y/org/preferences/:name
PUT /v1/o11y/orgs/me
PUT /v1/o11y/orgs/me/filters
PUT /v1/o11y/roles/:id
PUT /v1/o11y/route_policies/:id
PUT /v1/o11y/rules/:id
PUT /v1/o11y/sentinel/issues/:id
PUT /v1/o11y/service_accounts/:id
PUT /v1/o11y/service_accounts/:id/keys/:fid
PUT /v1/o11y/service_accounts/me
PUT /v1/o11y/trace-funnels/:funnel_id
PUT /v1/o11y/trace-funnels/steps/update
PUT /v1/o11y/user/preferences/:name
PUT /v1/o11y/users/me/dashboards/:id/pins
PUT /v1/o11y/zeus/hosts
PUT /v1/o11y/zeus/profiles
+19 -19
View File
@@ -40,31 +40,31 @@ import (
// parameterised /:funnel_id ones so a funnel id can never shadow them — the same
// discipline the mux tree keeps by registering the literals first.
func mountTraceFunnels(app *zip.App) {
g := rooted{app.Group(o11yRoot + "/trace-funnels"), o11yRoot + "/trace-funnels"}
g := app.Group(o11yRoot + "/trace-funnels")
opPost(g, "/new", funnelCreate, op("CreateTraceFunnel"))
opGet(g, "/list", funnelList, op("ListTraceFunnels"))
opPut(g, "/steps/update", funnelStepsUpdate, op("UpdateTraceFunnelSteps"))
g.Post("/new", funnelCreate, op("CreateTraceFunnel"))
g.Get("/list", funnelList, op("ListTraceFunnels"))
g.Put("/steps/update", funnelStepsUpdate, op("UpdateTraceFunnelSteps"))
// The ad-hoc family: the funnel travels in the body, nothing is stored.
opPost(g, "/analytics/validate", draftFunnelValidate, op("ValidateDraftFunnelTraces"))
opPost(g, "/analytics/overview", draftFunnelOverview, op("GetDraftFunnelOverview"))
opPost(g, "/analytics/steps", draftFunnelSteps, op("GetDraftFunnelStepMetrics"))
opPost(g, "/analytics/steps/overview", draftFunnelStepOverview, op("GetDraftFunnelStepOverview"))
opPost(g, "/analytics/slow-traces", draftFunnelSlowTraces, op("GetDraftFunnelSlowTraces"))
opPost(g, "/analytics/error-traces", draftFunnelErrorTraces, op("GetDraftFunnelErrorTraces"))
g.Post("/analytics/validate", draftFunnelValidate, op("ValidateDraftFunnelTraces"))
g.Post("/analytics/overview", draftFunnelOverview, op("GetDraftFunnelOverview"))
g.Post("/analytics/steps", draftFunnelSteps, op("GetDraftFunnelStepMetrics"))
g.Post("/analytics/steps/overview", draftFunnelStepOverview, op("GetDraftFunnelStepOverview"))
g.Post("/analytics/slow-traces", draftFunnelSlowTraces, op("GetDraftFunnelSlowTraces"))
g.Post("/analytics/error-traces", draftFunnelErrorTraces, op("GetDraftFunnelErrorTraces"))
opGet(g, "/:funnel_id", funnelGet, op("GetTraceFunnel"))
opPut(g, "/:funnel_id", funnelUpdate, op("UpdateTraceFunnel"))
opDelete(g, "/:funnel_id", funnelDelete, op("DeleteTraceFunnel"))
g.Get("/:funnel_id", funnelGet, op("GetTraceFunnel"))
g.Put("/:funnel_id", funnelUpdate, op("UpdateTraceFunnel"))
g.Delete("/:funnel_id", funnelDelete, op("DeleteTraceFunnel"))
// The saved family: the funnel is named by the path, the body is the window.
opPost(g, "/:funnel_id/analytics/validate", funnelValidate, op("ValidateTraceFunnelTraces"))
opPost(g, "/:funnel_id/analytics/overview", funnelOverview, op("GetTraceFunnelOverview"))
opPost(g, "/:funnel_id/analytics/steps", funnelSteps, op("GetTraceFunnelStepMetrics"))
opPost(g, "/:funnel_id/analytics/steps/overview", funnelStepOverview, op("GetTraceFunnelStepOverview"))
opPost(g, "/:funnel_id/analytics/slow-traces", funnelSlowTraces, op("GetTraceFunnelSlowTraces"))
opPost(g, "/:funnel_id/analytics/error-traces", funnelErrorTraces, op("GetTraceFunnelErrorTraces"))
g.Post("/:funnel_id/analytics/validate", funnelValidate, op("ValidateTraceFunnelTraces"))
g.Post("/:funnel_id/analytics/overview", funnelOverview, op("GetTraceFunnelOverview"))
g.Post("/:funnel_id/analytics/steps", funnelSteps, op("GetTraceFunnelStepMetrics"))
g.Post("/:funnel_id/analytics/steps/overview", funnelStepOverview, op("GetTraceFunnelStepOverview"))
g.Post("/:funnel_id/analytics/slow-traces", funnelSlowTraces, op("GetTraceFunnelSlowTraces"))
g.Post("/:funnel_id/analytics/error-traces", funnelErrorTraces, op("GetTraceFunnelErrorTraces"))
}
// ── the funnel itself ─────────────────────────────────────────────────────────
+7 -7
View File
@@ -39,13 +39,13 @@ import (
// so a trace id can never shadow the catalog — the same defence the mux tree
// gives them by registering fields first.
func mountTraces(app *zip.App) {
g := rooted{app.Group(o11yRoot), o11yRoot}
opGet(g, "/traces/fields", traceFields, op("GetTraceFields"))
opPost(g, "/traces/fields", traceFieldUpdate, op("UpdateTraceField"))
opGet(g, "/traces/:traceId", traceSpans, op("SearchTraces"))
opPost(g, "/traces/:traceId/waterfall", traceWaterfall, op("GetWaterfallV4"))
opPost(g, "/traces/:traceId/flamegraph", traceFlamegraph, op("GetFlamegraph"))
opPost(g, "/traces/:traceId/aggregations", traceAggregations, op("GetTraceAggregations"))
g := app.Group(o11yRoot)
g.Get("/traces/fields", traceFields, op("GetTraceFields"))
g.Post("/traces/fields", traceFieldUpdate, op("UpdateTraceField"))
g.Get("/traces/:traceId", traceSpans, op("SearchTraces"))
g.Post("/traces/:traceId/waterfall", traceWaterfall, op("GetWaterfallV4"))
g.Post("/traces/:traceId/flamegraph", traceFlamegraph, op("GetFlamegraph"))
g.Post("/traces/:traceId/aggregations", traceAggregations, op("GetTraceAggregations"))
}
// ── the six operations ────────────────────────────────────────────────────────