Files
o11y/routes_test.go
T
zeekay 4679327ddc
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
Sentinel reads the errors /v1/event lands, and takes none itself
The event plane folded every occurrence into event.fact, and the Sentry
module still read and wrote event.error. Its reads now select the error
signal of event.fact, mapping issue, env, origin and the severity band.

Errors enter through /v1/event only. The Sentry envelope and store
routes, DSN keys and their rotation leave the module; the consumer of
event.error files each fact through Ingest(org, product), which keeps
the issue lifecycle and returns the issues a batch created.
2026-09-30 01:15:34 -07:00

285 lines
9.5 KiB
Go

package o11y_test
import (
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"github.com/hanzoai/o11y"
"github.com/zap-proto/zip"
)
// THE CENSUS. This file is the arithmetic that the wildcard used to hide.
//
// While Mount ended in app.All("/v1/o11y/*", …) there was no way to tell a
// converted route from an unconverted one from a MISSING one: the catch-all
// answered all three the same. That is exactly how 83 typed ops shipped in
// files nothing called — the package built, the suite passed, and every one of
// those routes still fell through the wildcard. These tests are the replacement
// for that catch-all's silence.
// registered is every non-HEAD, non-OPTIONS route the mount actually installed.
func registered(t *testing.T, app *zip.App) map[string]bool {
t.Helper()
got := map[string]bool{}
for _, r := range app.Fiber().GetRoutes(true) {
if r.Method == http.MethodHead || r.Method == http.MethodOptions {
continue
}
got[r.Method+" "+r.Path] = true
}
return got
}
// assertRoutes proves a slice registered EXACTLY its own routes under prefix —
// no more, no fewer, at the same methods. Each slice counts only its own routes,
// so a slice that stops being mounted fails here rather than being quietly
// answered by something else.
func assertRoutes(t *testing.T, want map[string]bool, prefix string) {
t.Helper()
got := map[string]bool{}
for route := range registered(t, mounted(t)) {
_, path, _ := strings.Cut(route, " ")
if path == prefix || strings.HasPrefix(path, prefix+"/") {
got[route] = true
}
}
for route := range want {
if !got[route] {
t.Errorf("%s is not registered", route)
}
}
for route := range got {
if !want[route] {
t.Errorf("%s is registered but not in the census", route)
}
}
}
// THE WHOLE SURFACE, counted. 324 is the number of method+path pairs the o11y
// runtime registers on its gorilla/mux tree (pkg/query-service/app/routes_*.go
// and pkg/apiserver/o11yapiserver/*.go). Mount registers the same 324 — 318 as
// typed ops that carry a named In, a named Out and their prose into the
// document, 3 as named escape hatches, 3 as native service probes.
//
// If this number moves, one of two things happened and both need a human: a
// route was added to the runtime and not named here (it would 404 in the
// composed binary), or a route was named here that the runtime does not serve
// (it would 404 at the runtime). Neither is visible without the count.
func TestEveryRouteIsNamedAndCounted(t *testing.T) {
app := mounted(t)
all := registered(t, app)
const (
wantTyped = 318 // typed ops: in the OpenAPI document and the MCP tool list
wantHatches = 3 // mount.go mountHatches, each with its reason
wantProbes = 3 // health.go livez/healthz/readyz, native with fall-through
)
if len(all) != wantTyped+wantHatches+wantProbes {
t.Fatalf("mount registered %d routes, want %d", len(all), wantTyped+wantHatches+wantProbes)
}
// The typed ops are exactly the operations zip published. Counting them off
// the document rather than off the router is what makes this a payoff
// measurement and not a tautology: an op that reaches the router but not the
// document is the defect this whole conversion exists to remove.
spec := app.OpenAPISpec()
if got := len(app.MCPTools()); got != wantTyped {
t.Fatalf("MCP tools = %d, want %d", got, wantTyped)
}
paths, ok := spec["paths"].(map[string]map[string]any)
if !ok {
t.Fatalf("OpenAPI paths has type %T", spec["paths"])
}
ops := 0
for path, item := range paths {
if !strings.HasPrefix(path, "/v1/o11y") && !strings.HasPrefix(path, "/v1/o11y/sentinel") {
t.Errorf("OpenAPI published %s, which is not o11y's surface", path)
}
for method := range item {
switch method {
case "get", "post", "put", "patch", "delete":
ops++
}
}
}
if ops != wantTyped {
t.Fatalf("OpenAPI operations = %d, want %d", ops, wantTyped)
}
}
// 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.
func TestNoCatchAllRemains(t *testing.T) {
for route := range registered(t, mounted(t)) {
if strings.HasSuffix(route, "*") || strings.HasSuffix(route, "/+") {
t.Errorf("%s is a catch-all", route)
}
}
}
// A path under /v1/o11y that nothing named is a 404 — proof the catch-all is
// gone. Under the wildcard this same request reached the runtime, which is why
// an unmounted slice could go unnoticed.
func TestUnnamedPathIsNotServed(t *testing.T) {
app := mounted(t)
served := false
setRuntime(t, func(w http.ResponseWriter, r *http.Request) {
served = true
w.WriteHeader(http.StatusOK)
})
status, _ := call(t, app, member(http.MethodGet, "/v1/o11y/no-such-thing", nil))
if status != http.StatusNotFound {
t.Fatalf("status=%d want 404", status)
}
if served {
t.Fatal("an unnamed path still reached the runtime")
}
}
// setRuntime installs a stand-in runtime for the duration of the test.
func setRuntime(t *testing.T, fn http.HandlerFunc) {
t.Helper()
o11y.SetRuntime(o11y.Whole(fn))
t.Cleanup(func() { o11y.SetRuntime(nil) })
}
// THE THREE. Each hatch is registered at its exact method and path, and each
// delegates with the path UNTOUCHED — a rewrite at this seam can only ever move
// a request off its own route, and a mangler is invisible to the compiler, so
// this is the only thing that catches one coming back.
func TestHatchesDelegateVerbatim(t *testing.T) {
hatches := []struct{ method, path string }{
{http.MethodGet, "/v1/o11y/logs/livetail"},
{http.MethodGet, "/v1/o11y/query_progress"},
{http.MethodPost, "/v1/o11y/export_raw_data"},
}
if len(hatches) != 3 {
t.Fatalf("the census itself is wrong: %d", len(hatches))
}
app := mounted(t)
var saw string
setRuntime(t, func(w http.ResponseWriter, r *http.Request) {
saw = r.URL.Path
w.WriteHeader(http.StatusOK)
})
for _, h := range hatches {
t.Run(h.method+" "+h.path, func(t *testing.T) {
saw = ""
status, body := call(t, app, member(h.method, h.path, strings.NewReader("{}")))
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
if saw != h.path {
t.Fatalf("runtime received %q, want %q verbatim", saw, h.path)
}
})
}
}
// A hatch answers 503 rather than 404 while no runtime is registered — the same
// honest "not initialized yet" the wildcard gave, kept now that each hatch is
// its own route.
func TestHatchWithoutRuntimeIs503(t *testing.T) {
app := mounted(t)
o11y.SetRuntime(nil)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/o11y/logs/livetail", nil))
if err != nil {
t.Fatalf("Test: %v", err)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", resp.StatusCode)
}
}
// TestEveryOperationDeclaresItsOwnId holds what made this package's op target
// unnecessary.
//
// An op with no declared id takes zip's shape-derived one, and that one is
// qualified by the prefix of the occurrence it answers under — so registering
// through a group used to publish every undeclared op as "v1.o11y.<derived>",
// a rename that once cost 353 ids. A struct answering with a fixed prefix was
// the way around it. Once every op states its id, a declared id survives
// composition verbatim and the group is just a group; the test that measured
// that gap reached zero and said so, which is why the struct is gone.
//
// This is the same property from the other side: the number that has to stay at
// zero is the UNDECLARED one.
func TestEveryOperationDeclaresItsOwnId(t *testing.T) {
app := mounted(t)
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
var undeclared []string
for _, op := range app.Registry() {
if op.OperationID == "" {
undeclared = append(undeclared, op.Method+" "+op.Path)
}
}
if len(undeclared) != 0 {
t.Errorf("%d of %d ops declare no id, so composition would rename them: %v",
len(undeclared), len(app.Registry()), undeclared)
}
if len(app.Registry()) != 318 {
t.Errorf("registry holds %d ops, want the 318 of TestEveryRouteIsNamedAndCounted", len(app.Registry()))
}
// The property itself, on the real mount: nothing this service publishes
// carries the occurrence qualification.
for _, tool := range app.MCPTools() {
name, _ := tool["name"].(string)
if strings.HasPrefix(name, "v1.") {
t.Errorf("published name %q is prefix-qualified", name)
}
}
}