Bound the /metrics method label (closes #261)
All checks were successful
check / check (push) Successful in 3m6s

The `method` label was recorded as `r.Method` verbatim. net/http
accepts any RFC 9110 token as a method and passes it through, so the
label was bounded at nothing: 300 requests to `/` carrying random
12-character method tokens took a live scrape from 81 lines to 7,606,
unauthenticated and on a route with no rate limiter. This is the same
remote memory-exhaustion vector the handler label carried, reached
through a second dimension.

The fix goes in the same recorder seam that bounds the handler label,
which is renamed to reflect that it now bounds both. A method the
router can route is kept verbatim, so real methods stay
distinguishable; anything else carries the existing `(unmatched)`
sentinel, deliberately the same spelling rather than a second one for
the same idea. The bound is ten values.

The retained set is restated against the net/http constants because
chi's own methodMap is unexported. A token outside it can only ever
produce chi's 405, so folding those together loses nothing a scrape
could have used.

README's Metrics section gains the inbound HTTP metrics, the bound on
each of their labels, and the aggregate
`http_requests_inflight{handler="(all)"}` semantics.
This commit is contained in:
2026-08-23 23:47:04 +00:00
parent fd5966f807
commit 2ef52bbac2
5 changed files with 436 additions and 19 deletions

View File

@@ -97,20 +97,24 @@ func metricsTestRouter(
return r, reg
}
// drivePaths sends one POST per supplied path and returns how many
// responses carried each status code.
func drivePaths(
t *testing.T,
h http.Handler,
paths []string,
) map[int]int {
// probe is one request a cardinality assertion sends. Both label
// dimensions that have leaked are request-controlled — the path and
// the method — so both vary here and one driver sends them.
type probe struct {
method string
path string
}
// drive sends every probe and returns how many responses carried each
// status code.
func drive(t *testing.T, h http.Handler, probes []probe) map[int]int {
t.Helper()
codes := make(map[int]int)
for _, p := range paths {
for _, p := range probes {
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, p, nil,
t.Context(), p.method, p.path, nil,
)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -120,6 +124,25 @@ func drivePaths(
return codes
}
// drivePaths sends one POST per supplied path.
func drivePaths(
t *testing.T,
h http.Handler,
paths []string,
) map[int]int {
t.Helper()
probes := make([]probe, 0, len(paths))
for _, p := range paths {
probes = append(
probes, probe{method: http.MethodPost, path: p},
)
}
return drive(t, h, probes)
}
// receiverPaths returns n distinct /webhook/ paths, each naming a
// fresh UUID exactly as an unauthenticated flood would.
func receiverPaths(n int) []string {