mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
The five claim verbs need the address an operation will answer at, which is a root joined with a path. They used to ask the target for its prefix through a struct written to answer with one — and that struct existed for a second reason that has since gone: an operation with no declared id took a shape-derived one qualified by the prefix of the occurrence it answered under, so a plain group would have renamed every undeclared id. All 319 operations state their own id now, so a declared id survives composition verbatim and the group is just a group. The roots are this package's own constants, so each group carries the one it was made with and nothing can disagree about the address. The escape hatches name their whole address and register through Raw, which says which method each one answers. The test that measured the gap reached zero and said so; it now holds the property from the other side, that no operation goes undeclared. Co-authored-by: Hanzo Dev <[email protected]>
245 lines
11 KiB
Go
245 lines
11 KiB
Go
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) })
|
|
}
|