Label HTTP metrics with the chi route pattern (closes #254) #258
Reference in New Issue
Block a user
Delete Branch "issue-254-metrics-route-pattern"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #254.
The defect
Middleware.Metricscalledstd.Handler("", ...). An empty handler id isgo-http-metrics' signal to substitutereporter.URLPath(), so thehandlerlabel carried the concrete request path. Every distinct/webhook/<uuid>minted a permanent label set, and the entrypoint UUID — the receiver's only credential — was published verbatim in the scrape.Why the pattern could not just be passed in
The recorder is global middleware (
Server.setupGlobalMiddleware), so it is entered before chi has matched anything, andMiddleware.Measurefixes its handler id up front, before calling the wrapped handler. ReadingRoutePattern()at that point yields""for every request — which would collapse every series into one bucket and look like a pass.What the library does hand over unchanged is the request context, on every recorder call, and the duration and size observations happen in a
deferafter the wrapped handler returns. That context holds the same*chi.Contextpointer routing mutates in place. It is the same pointaccessLogURLalready reads the pattern from, and the same one the Sentry scrub uses.So the fix is a
metrics.Recorderdecorator that rewritesprops.IDfrom the route context at record time.std.Handlerand itsresponseWriterInterceptorare untouched, so status-code and byte capture are unchanged (pinned byTestMetrics_StatusAndSizeStillRecorded).Decisions this makes
Unmatched routes carry the existing
unmatchedRoutesentinel,(unmatched)— the same fixed value the access log already uses for a path that matched nothing. Pinned byTestMetrics_UnmatchedPathsCollapseToTheSentinelover two path shapes (no matching prefix, and a receiver-prefixed path with too many segments), and verified live below.http_requests_inflightgets a fixedhandler="(all)", not a pattern. The gauge is incremented before routing and decremented after, and the pattern exists only between those two moments — a route-derived label would increment one series and decrement another, leaving every pattern permanently off by the number of requests it served. One aggregate series, counting requests in flight across the service;TestMetrics_InflightGaugeIsAggregateAndBalancedpins that it balances back to zero. This is the one observable behaviour change beyond the label domain, and it is called out here rather than buried.The handler id passed to
std.Handleris the sentinel rather than"", so the client-chosen URL path never enters the metrics pipeline at all. The decorator overwrites it on every observation; if the decorator were ever removed the metrics would collapse to one series rather than start leaking again.Verification
make checkgreen,GOFLAGS=-count=1, lint in Docker (0 issues., 56s, not cached). All 20 packagesokwith no(cached)lines.Six new tests in
internal/middleware/metrics_test.go, on a router whose ordering mirrors the real server's — recorder global, receiver rate limiter route-level. Five of the six were confirmed to FAIL against the unfixed recorder before being kept.Live probe, the one from the issue. Server built from this branch's parent, then from this branch,
METRICS_USERNAME/METRICS_PASSWORDset, 3,000 unauthenticated POSTs to distinct invented entrypoint UUIDs per flood.next)handlerlabelsThe four labels after the flood are
/webhook/{uuid},/metrics,/.well-known/healthcheckand(all).Rate-limited path, explicitly. 4,800 of the driven requests were rejected 429 by the route-level receiver limiter. All of them landed on the pattern — 25 series total, every one
handler="/webhook/{uuid}":Unmatched-route flood, live. 50 requests to distinct
/<uuid>paths added the(unmatched)label and took the scrape to 206 series; a further 200 requests to distinct/<uuid>/xpaths left it at 206 series, 5 handler labels, 0 UUIDs.TODO.mddeliberately untouched, per #112.PASS. Independently reproduced and verified: parent 107 -> 62,532 series / 2,402
handlerlabels / 62,400 UUID-shaped strings; this branch 106 -> 206 series / 5 labels / 0 UUIDs, flat across a second flood, all 704 429s onhandler="/webhook/{uuid}". Aliasing assumption checked against chi v1.5.5 source (rctx installed byMux.ServeHTTPbefore anyUsemiddleware,pool.Putonly after the chain returns, sub-routers reuse the same pointer) and under-racewith 1,280 concurrent requests plus concurrentGather()— clean.make checkgreen from a clean clone,GOFLAGS=-count=1, 88s, 20 packagesok, zero(cached), lint0 issues.in 49.5s. Both declared decisions ruled sound: the(unmatched)sentinel cannot be spoofed (the label never derives from the path), and per-route inflight is genuinely unreachable throughRecorder—Measurebinds the decrement's args atdefertime, so it would resolve after routing while the increment resolved before it.Anomalies and disclosures, none blocking:
Pre-existing, not this PR's to fix, but it leaves #254's headline only partly closed. The
methodlabel is stillr.Methodverbatim, andnet/httpaccepts any RFC token. Against this branch's build, 300 requests to/with random 12-character method tokens took the scrape from 106 to 7,631 series (~25 permanent series per distinct token), never evicted, unauthenticated, on a route with no rate limiter. Every DoD bullet in the issue is satisfied — this is a different label, untouched by this diff — but the unbounded-growth vector the issue title names survives. Suggest a follow-up issue rather than scope creep here.When a sub-router-level middleware answers before the child route matches (
RequireAuth,CSRF), the label is the parent mount's wildcard —/pages/*,/source/{sourceID}/*,/sources/*— not the full pattern. Correct and bounded by the route table, and consistent withaccessLogURL; noted because it is surprising. Requests that reach the child do get the full pattern (verified live:/pages/login->handler="/pages/login").Fail-first claim spot-checked by removing the decorator in a throwaway clone: exactly 5 of 6 fail,
TestMetrics_StatusAndSizeStillRecordedpasses both ways — as its comment says, it is a regression guard, not a fail-first test.The
http_requests_inflightsemantics change is documented in the PR body and the code but not in README's Metrics section, which is where the same cardinality argument is made for the delivery metrics.Nits, non-blocking:
middleware.MetricsMiddlewareForTeststutters;UnmatchedRouteConst/InflightHandlerConstdiverge from the file's existing...ForTestconvention;routePatternIDduplicates the RouteContext/RoutePattern/sentinel logic inaccessLogURL, which could now call it.Disclosure. Committed
internal/middleware/metrics.gowas read in full and confirmed free of substitution damage: tree clean,fmt-checkand lint green, and a revert/restore round-trip leftgit statusempty. The decorator removal and a scratch concurrency test were done in a separate throwaway clone, never in the PR tree; both were reverted and that clone is clean. CI green on6e26885(3m1s); head is one commit onorigin/next, fast-forward. No Claude/Anthropic references anywhere.