Files
o11y/tracefunnels_test.go
zeekayandHanzo Dev 2d4b46d8ee mount: the wildcard is gone — every one of 367 routes is named
The delegation catch-all made three facts indistinguishable: a route that was
converted, a route that was not, and a route that did not exist. That is not a
theoretical cost — it is exactly how 83 typed ops shipped in files nothing
called, passing a full green suite while every one of those routes fell through
the wildcard.

So: derive the gap precisely, close it, and delete the catch-all.

THE GAP, re-derived. Extract every path the runtime registers (134 in
pkg/query-service/app/routes_*.go, 233 in pkg/apiserver/o11yapiserver/*.go — the
134 matching the count routes.go documents), normalise :params on both sides,
and diff against what Mount installs. 367 runtime routes, 43 unnamed. Not the
four hatch classes the plan expected: eleven were genuine hatches and
THIRTY-TWO were simply never converted.

CONVERTED (32 new typed ops, three new slices):
  * traces.go       — a trace's spans, the trace field catalog and its tuning
                      write, waterfall, flamegraph, span aggregations
  * tracefunnels.go — funnel CRUD plus twelve analytics reads, six over a saved
                      funnel and six over one described inline
  * spanmappers.go  — the ingest-time attribute mapping groups and their mappers

THE ELEVEN HATCHES, each its own route literal with its reason beside it
(mount.go mountHatches): four streams with no single answer to name (livetail,
query_progress long-poll, its websocket, the chunked export), three 303
redirects whose answer is a Location and not a body, and four Sentry-SDK ingest
doors — a foreign protocol we RECEIVE, whose /api/ segment the SDK hardcodes and
which is not ours to rename.

TWO OF THOSE ELEVEN WERE UNREACHABLE. /ws/query_progress and the two
/v1/sentry/<project>/{envelope,store}/ doors sit outside /v1/o11y, so the old
catch-all never saw them: a Sentry SDK pointed at the clean root got a 404 in
the composed binary, and telemetry_test.go passed only because the test
registered the wildcard the deployment did not have. Naming every route is what
surfaced it.

ONE SEAM, TWO ROOTS. Five copies of the relay function (relay, relayAt,
relayO11y, relayAPM, send) differing only in a prefix and whether they took
query parameters, and NINE names for two values — identityPrefix, infraPrefix,
integrationsPrefix, metricsRoot, apmPrefix, accessPrefix, rulesAlertsPrefix and
o11yRoot were all "/v1/o11y". Ops now pass the whole public path and relay
concatenates nothing: 193 call sites, one implementation, -520 lines.

Also: the per-slice *_zipdoc.go files fold back into zipdoc_gen.go as their own
headers instructed; the health probes' fall-through names its destination
instead of relying on a catch-all being registered after it (they would have
started 404ing the moment SetHealth was late — precisely when liveness matters);
O11yLogField/O11yLogFieldsOut/O11yLogFieldSetting become
O11yTelemetryField/O11yFieldCatalogOut/O11yFieldSetting because logs and traces
tune ONE catalog through one runtime type.

PAYOFF, counted off the document rather than the router: 353 operations in the
published OpenAPI spec and 353 MCP tools, from 0 for this surface before the
conversion began. 353 typed + 11 hatches + 3 probes = 367.

RECORDED, NOT FIXED: the runtime renders with jsoniter and zip with the stdlib
encoder, and jsoniter does not implement `,omitzero` — so for the seventeen
fields across pkg/types that use it the runtime writes the zero value where a
typed op omits the key. Pre-existing on every shipped op reaching
TelemetryFieldKey. Pinned by traces_test.go rather than hidden.

gorilla/mux and gin stay: mux is the runtime's own router in 100 files, and gin
is imported only for its form binder by pkg/valuer/valuer.go
(ginbinding.BindUnmarshaler) and pkg/http/binding/query.go
(ginbinding.MapFormWithTag). go mod tidy dropped 687 lines of go.mod/go.sum.

Build and full suite green: 133 packages ok, 0 fail.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 17:15:37 -07:00

182 lines
6.6 KiB
Go

package o11y_test
import (
"net/http"
"strings"
"testing"
v3 "github.com/hanzoai/o11y/pkg/query-service/model/v3"
tf "github.com/hanzoai/o11y/pkg/types/tracefunneltypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// THE WIRE PROOF for the trace-funnels face. Same harness as every other slice:
// the bytes the RUNTIME writes go in, the op's answer comes out, and they must
// be the same bytes — plus the method and path the op asked the runtime for.
// fullFunnel is one funnel with every field populated, so a dropped or renamed
// field cannot hide behind a zero value.
func fullFunnel() tf.GettableFunnel {
return tf.GettableFunnel{
FunnelID: "6ba7b810-9dad-11d1-80b4-00c04fd430c8", FunnelName: "checkout",
Description: "cart → pay → done", CreatedAt: 1_760_000_000_000, CreatedBy: "z",
UpdatedAt: 1_760_000_009_000, UpdatedBy: "z", OrgID: "maxpower",
UserEmail: "[email protected]",
Steps: []*tf.FunnelStep{{
ID: valuer.MustNewUUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8"),
Name: "cart", Description: "add to cart", Order: 1,
ServiceName: "web", SpanName: "POST /cart",
Filters: &v3.FilterSet{Operator: "AND"},
LatencyPointer: "start", LatencyType: "p99", HasErrors: false,
}},
}
}
func TestFunnelReadsAnswerTheRuntimeAnswer(t *testing.T) {
one := fullFunnel()
for _, tc := range []struct {
name, method, path, target string
payload any
body string
}{
{"create", http.MethodPost, "/v1/o11y/trace-funnels/new", "/v1/o11y/trace-funnels/new",
one, `{"funnel_name":"checkout","timestamp":1760000000000}`},
{"list", http.MethodGet, "/v1/o11y/trace-funnels/list", "/v1/o11y/trace-funnels/list",
[]tf.GettableFunnel{one}, ""},
{"get", http.MethodGet, "/v1/o11y/trace-funnels/f-1", "/v1/o11y/trace-funnels/f-1",
one, ""},
{"update", http.MethodPut, "/v1/o11y/trace-funnels/f-1", "/v1/o11y/trace-funnels/f-1",
one, `{"funnel_name":"checkout v2"}`},
{"steps", http.MethodPut, "/v1/o11y/trace-funnels/steps/update", "/v1/o11y/trace-funnels/steps/update",
one, `{"funnel_id":"f-1","steps":[]}`},
{"delete", http.MethodDelete, "/v1/o11y/trace-funnels/f-1", "/v1/o11y/trace-funnels/f-1",
nil, ""},
} {
t.Run(tc.name, func(t *testing.T) {
app := mounted(t)
want := rendered(t, http.StatusOK, tc.payload)
asked := logsRuntime(t, http.StatusOK, want)
var body *strings.Reader
if tc.body != "" {
body = strings.NewReader(tc.body)
}
var req *http.Request
if body == nil {
req = member(tc.method, tc.path, nil)
} else {
req = member(tc.method, tc.path, body)
}
status, got := call(t, app, req)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, got)
}
if string(got) != string(want) {
t.Fatalf("op answered %s, runtime wrote %s", got, want)
}
if r := *asked; r.Method != tc.method || r.URL.Path != tc.target {
t.Fatalf("runtime asked %s %s, want %s %s", r.Method, r.URL.Path, tc.method, tc.target)
}
})
}
}
// The twelve analytics reads all answer the same table, and each must reach its
// OWN path. The saved family carries the funnel id on the path; the ad-hoc
// family carries the funnel in the body and must NOT invent an id segment.
func TestFunnelAnalyticsReachTheirOwnPaths(t *testing.T) {
rows := []v3.Row{{Data: map[string]any{"conversion_rate": 0.42, "total": float64(100)}}}
views := []string{"validate", "overview", "steps", "steps/overview", "slow-traces", "error-traces"}
for _, view := range views {
for _, saved := range []bool{true, false} {
path := "/v1/o11y/trace-funnels/analytics/" + view
if saved {
path = "/v1/o11y/trace-funnels/f-1/analytics/" + view
}
t.Run(path, func(t *testing.T) {
app := mounted(t)
want := enveloped(t, rows)
asked := logsRuntime(t, http.StatusOK, want)
status, got := call(t, app, member(http.MethodPost, path,
strings.NewReader(`{"start_time":1,"end_time":2,"step_start":1,"step_end":2,"steps":[]}`)))
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, got)
}
if string(got) != string(want) {
t.Fatalf("op answered %s, runtime wrote %s", got, want)
}
if r := *asked; r.Method != http.MethodPost || r.URL.Path != path {
t.Fatalf("runtime asked %s %s, want POST %s", r.Method, r.URL.Path, path)
}
})
}
}
if len(views) != 6 {
t.Fatalf("the census itself is wrong: %d", len(views))
}
}
// A funnel id is a path segment the router matched, and it goes on VERBATIM.
func TestFunnelIDGoesOnVerbatim(t *testing.T) {
app := mounted(t)
asked := logsRuntime(t, http.StatusOK, rendered(t, http.StatusOK, fullFunnel()))
call(t, app, member(http.MethodGet, "/v1/o11y/trace-funnels/a.b-c_d", nil))
if got := (*asked).URL.Path; got != "/v1/o11y/trace-funnels/a.b-c_d" {
t.Fatalf("runtime saw %q", got)
}
}
// The literal routes must beat the :funnel_id parameter — /list is a listing,
// not a funnel named "list". The mux tree gets this from registration order;
// here it is asserted, because a Fiber tree that ever changed its precedence
// rule would otherwise fail silently at a customer.
func TestFunnelLiteralsBeatTheParameter(t *testing.T) {
for _, path := range []string{
"/v1/o11y/trace-funnels/list",
"/v1/o11y/trace-funnels/analytics/overview",
} {
t.Run(path, func(t *testing.T) {
app := mounted(t)
asked := logsRuntime(t, http.StatusOK, enveloped(t, []v3.Row{}))
method := http.MethodGet
var body *strings.Reader
if strings.Contains(path, "analytics") {
method, body = http.MethodPost, strings.NewReader("{}")
}
if body == nil {
call(t, app, member(method, path, nil))
} else {
call(t, app, member(method, path, body))
}
if *asked == nil {
t.Fatal("the runtime was never asked — the literal lost to the parameter")
}
if got := (*asked).URL.Path; got != path {
t.Fatalf("runtime saw %q, want %q", got, path)
}
})
}
}
func TestTraceFunnelRoutesAreTheSameEighteen(t *testing.T) {
want := map[string]bool{
"POST /v1/o11y/trace-funnels/new": true,
"GET /v1/o11y/trace-funnels/list": true,
"PUT /v1/o11y/trace-funnels/steps/update": true,
"GET /v1/o11y/trace-funnels/:funnel_id": true,
"PUT /v1/o11y/trace-funnels/:funnel_id": true,
"DELETE /v1/o11y/trace-funnels/:funnel_id": true,
}
for _, view := range []string{"validate", "overview", "steps", "steps/overview", "slow-traces", "error-traces"} {
want["POST /v1/o11y/trace-funnels/analytics/"+view] = true
want["POST /v1/o11y/trace-funnels/:funnel_id/analytics/"+view] = true
}
if len(want) != 18 {
t.Fatalf("the census itself is wrong: %d", len(want))
}
assertRoutes(t, want, "/v1/o11y/trace-funnels")
}