method was recorded as r.Method verbatim. net/http accepts any RFC 9110 token as a method and hands it through, so the label was bounded at nothing.
The fix goes in the recorder seam #258 added for handler — renamed routePatternRecorder -> boundedLabelRecorder, since it now bounds both request-controlled dimensions. A method the router can route is kept verbatim; anything else carries the existing (unmatched) sentinel, aliased (unmatchedMethod = unmatchedRoute) rather than respelled, so one idea does not read as two unmatched states in a scrape. Bound: 10 values.
The retained set is the nine of chi's methodMap, restated against the net/http constants because chi does not export it. A token outside it can only ever produce chi's 405, so folding those together loses nothing a scrape could have used. chi.RegisterMethod would extend the router's set; this service never calls it (verified: grep -rn RegisterMethod over internal/ and cmd/ returns nothing), and the code comment says a caller that started to must extend the switch with it.
Reproduced first, then fixed
Both binaries built with make build, run with METRICS_USERNAME/METRICS_PASSWORD set, driven with 300 random 12-character method tokens against /, counting non-comment lines from an authenticated /metrics scrape:
Binary
Lines before
Lines after 300 tokens
next at fd5966f (unmodified)
81
7,606
this branch
331
331
The next delta of 7,525 lines matches the issue's measurement exactly (it reported 106 -> 7,631 against a warmer baseline). The branch's higher starting count is only because that instance had already been driven with real traffic for the audit below.
The same reproduction at test scale, TestMetrics_MethodSeriesCountIsFlatUnderAFlood run against the unmodified recorder before the two-line wiring went in:
before the fix: 300 tokens -> 601 series / 7,501 lines; 1,200 more -> 3,001 series / 37,501 lines (test failed)
after the fix: 300 tokens -> 3 series / 26 lines; 1,200 more -> 3 series / 26 lines (test passes, and logs both figures)
Live label values from the fixed binary, showing the invented token ZQXJ7BK2M4V9 collapsed while the real methods stayed apart:
Enumerated from a live authenticated scrape of a running instance rather than from reading alone — curl -u ... /metrics, every family and its label names extracted. That is the complete list of what /metrics exposes; nothing below was assumed present or absent.
code: traced the value to middleware.Measure in go-http-metrics, which formats Reporter.StatusCode(); std.stdReporter.StatusCode() returns responseWriterInterceptor.statusCode, an int assigned only in WriteHeader. Nothing copies a request header or path into it. A request cannot name its own status.
service: metricsMiddleware constructs ghmm.New(ghmm.Config{Recorder: ...}) and sets no Service, so the field is "" for the process lifetime. Confirmed empty in the live scrape above.
target_type: every call site in internal/metrics/metrics.go goes through normalizeTargetType, and queueDepthLabels/initSeries enumerate the domain up front. A database.TargetType outside the four constants collapses to unknown. internal/delivery and internal/database were read for the call sites and not modified.
The go_*/process_*/promhttp_* families are registered by client_golang on the default registry, which is the one promhttp.Handler() gathers; their labels are compile-time constants of those collectors. Their presence and label sets are from the live scrape, not assumed.
webhooker_delivery_duration_seconds does not appear in a scrape until the first delivery, because initSeries does not materialise it (unlike the counters and gauges). Its labels are target_type + le, both bounded as above. Noted as an observation, not a defect.
No third unbounded dimension was found.
Tests
New, in internal/middleware/metrics_method_test.go:
TestMetrics_InventedMethodsMintOneLabelSet — 300 distinct invented tokens, exactly one method label, and no token echoed into any label. Asserts on the count rather than the set so a regression reports a number instead of dumping 300 tokens.
TestMetrics_MethodSeriesCountIsFlatUnderAFlood — scrape, 300 tokens, scrape, 1,200 more, scrape; both series count and rendered exposition-line count must be flat. Lines are counted through promhttp.HandlerFor, i.e. the same handler /metrics mounts, because one histogram label set is ~25 lines and the line count is what the issue measured.
TestMetrics_RealMethodsStayDistinct — the positive control. All nine routable methods (GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH, TRACE, CONNECT) keep their own duration series under the route pattern, one sample each.
TestMetrics_MethodSentinelIsTheRouteSentinel — pins that the two sentinels are one string, so a future edit cannot introduce a second spelling.
drivePaths in metrics_test.go was refactored onto a shared drive/probe helper, since the probes now vary the method as well as the path; its signature and behaviour are unchanged.
Verification
make check green with GOFLAGS=-count=1: 580 tests pass across 20 packages, golangci-lint reports 0 issues. in the Docker lint stage (summary line present, so the linter ran), fmt-check clean. All probe processes stopped and scratch removed; ports 18961/18962 released.
Out of scope and untouched: no new metrics, no changes under internal/delivery or internal/database, no TODO.md edit.
Closes https://git.eeqj.de/sneak/webhooker/issues/261.
`method` was recorded as `r.Method` verbatim. `net/http` accepts any RFC 9110 token as a method and hands it through, so the label was bounded at nothing.
The fix goes in the recorder seam https://git.eeqj.de/sneak/webhooker/pulls/258 added for `handler` — renamed `routePatternRecorder` -> `boundedLabelRecorder`, since it now bounds both request-controlled dimensions. A method the router can route is kept verbatim; anything else carries the existing `(unmatched)` sentinel, aliased (`unmatchedMethod = unmatchedRoute`) rather than respelled, so one idea does not read as two unmatched states in a scrape. Bound: 10 values.
The retained set is the nine of chi's `methodMap`, restated against the `net/http` constants because chi does not export it. A token outside it can only ever produce chi's 405, so folding those together loses nothing a scrape could have used. `chi.RegisterMethod` would extend the router's set; this service never calls it (verified: `grep -rn RegisterMethod` over `internal/` and `cmd/` returns nothing), and the code comment says a caller that started to must extend the switch with it.
## Reproduced first, then fixed
Both binaries built with `make build`, run with `METRICS_USERNAME`/`METRICS_PASSWORD` set, driven with 300 random 12-character method tokens against `/`, counting non-comment lines from an authenticated `/metrics` scrape:
| Binary | Lines before | Lines after 300 tokens |
| ------ | ------------ | ---------------------- |
| `next` at `fd5966f` (unmodified) | 81 | **7,606** |
| this branch | 331 | **331** |
The `next` delta of 7,525 lines matches the issue's measurement exactly (it reported 106 -> 7,631 against a warmer baseline). The branch's higher starting count is only because that instance had already been driven with real traffic for the audit below.
The same reproduction at test scale, `TestMetrics_MethodSeriesCountIsFlatUnderAFlood` run against the unmodified recorder before the two-line wiring went in:
- before the fix: 300 tokens -> 601 series / 7,501 lines; 1,200 more -> 3,001 series / 37,501 lines (test **failed**)
- after the fix: 300 tokens -> 3 series / 26 lines; 1,200 more -> 3 series / 26 lines (test **passes**, and logs both figures)
Live label values from the fixed binary, showing the invented token `ZQXJ7BK2M4V9` collapsed while the real methods stayed apart:
```
http_request_duration_seconds_count{code="200",handler="/.well-known/healthcheck",method="GET",service=""} 1
http_request_duration_seconds_count{code="303",handler="/",method="GET",service=""} 1
http_request_duration_seconds_count{code="404",handler="(unmatched)",method="GET",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="(unmatched)",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="DELETE",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="HEAD",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="OPTIONS",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="POST",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="PUT",service=""} 1
http_requests_inflight{handler="(all)",service=""} 1
```
## Label audit
Enumerated from a live authenticated scrape of a running instance rather than from reading alone — `curl -u ... /metrics`, every family and its label names extracted. That is the complete list of what `/metrics` exposes; nothing below was assumed present or absent.
| Metric | Label | Populated by | Attacker-controlled? | Bound |
| ------ | ----- | ------------ | -------------------- | ----- |
| `http_request_duration_seconds`, `http_response_size_bytes` | `handler` | chi route pattern via `routePatternID`, sentinel when unmatched | no, since #258 | registered patterns + 1 |
| same two | `method` | `methodID(r.Method)` | **was yes — this PR** | 9 + 1 = 10 |
| same two | `code` | `strconv.Itoa(reporter.StatusCode())` in go-http-metrics, where `StatusCode()` reads the interceptor field written only by `WriteHeader` | no | status codes this service's own handlers write; `net/http` rejects anything outside 100-999 |
| same two, and `http_requests_inflight` | `service` | `ghmm.Config.Service`, left unset in `metricsMiddleware` | no | 1 (empty string) |
| `http_requests_inflight` | `handler` | fixed `inflightHandler` constant | no | 1 (`(all)`) |
| `http_request_duration_seconds`, `http_response_size_bytes` | `le` | histogram bucket bounds fixed at construction | no | fixed per metric |
| all 8 labelled `webhooker_*` delivery metrics | `target_type` | `normalizeTargetType`, which folds anything outside `knownTargetTypes` to `unknown` | no — set by an authenticated operator, and folded regardless | 4 + 1 = 5 |
| `webhooker_events_received_total`, `webhooker_events_resubmitted_total` | — | no labels | n/a | 1 series each |
| `promhttp_metric_handler_requests_total` | `code` | promhttp's own instrumentation | no | 3 (`200`, `500`, `503`), observed in the scrape |
| `go_gc_duration_seconds` | `quantile` | Go collector | no | 5 |
| `go_info` | `version` | Go build version | no | 1 |
| remaining `go_*`, `process_*`, `promhttp_metric_handler_requests_in_flight` | — | no labels | n/a | 1 series each |
How each `no` was verified:
- `code`: traced the value to `middleware.Measure` in go-http-metrics, which formats `Reporter.StatusCode()`; `std.stdReporter.StatusCode()` returns `responseWriterInterceptor.statusCode`, an `int` assigned only in `WriteHeader`. Nothing copies a request header or path into it. A request cannot name its own status.
- `service`: `metricsMiddleware` constructs `ghmm.New(ghmm.Config{Recorder: ...})` and sets no `Service`, so the field is `""` for the process lifetime. Confirmed empty in the live scrape above.
- `target_type`: every call site in `internal/metrics/metrics.go` goes through `normalizeTargetType`, and `queueDepthLabels`/`initSeries` enumerate the domain up front. A `database.TargetType` outside the four constants collapses to `unknown`. `internal/delivery` and `internal/database` were read for the call sites and not modified.
- The `go_*`/`process_*`/`promhttp_*` families are registered by `client_golang` on the default registry, which is the one `promhttp.Handler()` gathers; their labels are compile-time constants of those collectors. Their presence and label sets are from the live scrape, not assumed.
`webhooker_delivery_duration_seconds` does not appear in a scrape until the first delivery, because `initSeries` does not materialise it (unlike the counters and gauges). Its labels are `target_type` + `le`, both bounded as above. Noted as an observation, not a defect.
No third unbounded dimension was found.
## Tests
New, in `internal/middleware/metrics_method_test.go`:
- `TestMetrics_InventedMethodsMintOneLabelSet` — 300 distinct invented tokens, exactly one `method` label, and no token echoed into any label. Asserts on the count rather than the set so a regression reports a number instead of dumping 300 tokens.
- `TestMetrics_MethodSeriesCountIsFlatUnderAFlood` — scrape, 300 tokens, scrape, 1,200 more, scrape; both series count and rendered exposition-line count must be flat. Lines are counted through `promhttp.HandlerFor`, i.e. the same handler `/metrics` mounts, because one histogram label set is ~25 lines and the line count is what the issue measured.
- `TestMetrics_RealMethodsStayDistinct` — the positive control. All nine routable methods (`GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `PATCH`, `TRACE`, `CONNECT`) keep their own duration series under the route pattern, one sample each.
- `TestMetrics_MethodSentinelIsTheRouteSentinel` — pins that the two sentinels are one string, so a future edit cannot introduce a second spelling.
`drivePaths` in `metrics_test.go` was refactored onto a shared `drive`/`probe` helper, since the probes now vary the method as well as the path; its signature and behaviour are unchanged.
## Verification
`make check` green with `GOFLAGS=-count=1`: 580 tests pass across 20 packages, `golangci-lint` reports `0 issues.` in the Docker lint stage (summary line present, so the linter ran), `fmt-check` clean. All probe processes stopped and scratch removed; ports 18961/18962 released.
Out of scope and untouched: no new metrics, no changes under `internal/delivery` or `internal/database`, no `TODO.md` edit.
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.
PASS — independently reproduced and verified at 2ef52bb: on parent fd5966f my own probe took a live scrape from 106 to 7,631 lines / 301 method values under 300 random 12-char tokens (601 after 600 tokens); this branch went 106 to 156 and stayed flat at 156 across a doubled flood. Positive control live: all nine chi methods keep distinct series with correct code; 200 / 404 / 405 / 429 each landed with the right code and method. Independent label enumeration from a live scrape found the complete label-name set to be code, handler, le, method, quantile, service, target_type, version — each traced to a bounded writer (promhttp_metric_handler_requests_total observed at exactly 200/500/503); no third unbounded dimension. make check green from a clean clone with GOFLAGS=-count=1 in 1m25s: 580 tests / 20 packages / zero (cached) markers / 0 failures, lint 0 issues. in the Docker stage under --no-cache-filter=lint (55.5s, not a cache hit), fmt-check clean. CI green, mergeable, no attribution trailers, (closes #261) present.
Anomalies and disclosures, none blocking:
Case folding is correct and deliberate, worth recording.get, Post and gEt all fold onto the sentinel. That is faithful to the router, not an accident: chi v1.5.5 upper-cases only at registration (Mux.Method), while routeHTTP does a bare methodMap[rctx.RouteMethod] lookup, so a lowercase get can only ever 405. Confirmed live.
chi's methodMap matches the switch exactly — same nine net/http constants in chi v1.5.5, and RegisterMethod is called nowhere in the repo or in any vendored go-chi package. Residual risk of the duplicated source of truth: TestMetrics_RealMethodsStayDistinct would catch chi dropping a method but not chi adding one, which would silently fold onto (unmatched). No clean guard exists while the map stays unexported, so I am waiving this rather than requiring it.
Sentinel aliasing is fine.handler and method are separate label names and never merge series, and by construction method="(unmatched)" implies handler="(unmatched)" (chi 405s before it matches a pattern). So (unmatched)/(unmatched) reads as a bad method and (unmatched)/GET as a bad path — distinguishable, verified live.
Cosmetic, failure-path only: internal/middleware/metrics_method_test.go:189assert.Contains(t, keys(labels), ...) dumps all 300 tokens when it fails, which is the thing the comment three lines above says the count-based assertion was chosen to avoid.
Disclosure: to confirm fail-first I temporarily reverted the two props.Method = methodID(...) lines in my own throwaway clone, ran make test (both cardinality tests failed at 601 to 3,001 series / 7,501 to 37,501 lines, matching the PR body; the positive control correctly passed both before and after), then restored. Nothing was changed, committed or pushed here.
On the deliveryDuration observation: confirmed — webhooker_delivery_duration_seconds is genuinely absent from a fresh scrape. Real, out of scope for this PR, and worth a follow-up issue against #209. Not required here.
**PASS** — independently reproduced and verified at `2ef52bb`: on parent `fd5966f` my own probe took a live scrape from 106 to 7,631 lines / 301 `method` values under 300 random 12-char tokens (601 after 600 tokens); this branch went 106 to 156 and stayed flat at 156 across a doubled flood. Positive control live: all nine chi methods keep distinct series with correct `code`; 200 / 404 / 405 / 429 each landed with the right `code` and `method`. Independent label enumeration from a live scrape found the complete label-name set to be `code`, `handler`, `le`, `method`, `quantile`, `service`, `target_type`, `version` — each traced to a bounded writer (`promhttp_metric_handler_requests_total` observed at exactly `200`/`500`/`503`); no third unbounded dimension. `make check` green from a clean clone with `GOFLAGS=-count=1` in 1m25s: 580 tests / 20 packages / zero `(cached)` markers / 0 failures, lint `0 issues.` in the Docker stage under `--no-cache-filter=lint` (55.5s, not a cache hit), `fmt-check` clean. CI green, mergeable, no attribution trailers, `(closes #261)` present.
Anomalies and disclosures, none blocking:
- **Case folding is correct and deliberate, worth recording.** `get`, `Post` and `gEt` all fold onto the sentinel. That is faithful to the router, not an accident: chi v1.5.5 upper-cases only at *registration* (`Mux.Method`), while `routeHTTP` does a bare `methodMap[rctx.RouteMethod]` lookup, so a lowercase `get` can only ever 405. Confirmed live.
- **chi's `methodMap` matches the switch exactly** — same nine `net/http` constants in chi v1.5.5, and `RegisterMethod` is called nowhere in the repo or in any vendored `go-chi` package. Residual risk of the duplicated source of truth: `TestMetrics_RealMethodsStayDistinct` would catch chi *dropping* a method but not chi *adding* one, which would silently fold onto `(unmatched)`. No clean guard exists while the map stays unexported, so I am waiving this rather than requiring it.
- **Sentinel aliasing is fine.** `handler` and `method` are separate label names and never merge series, and by construction `method="(unmatched)"` implies `handler="(unmatched)"` (chi 405s before it matches a pattern). So `(unmatched)`/`(unmatched)` reads as a bad method and `(unmatched)`/`GET` as a bad path — distinguishable, verified live.
- Cosmetic, failure-path only: `internal/middleware/metrics_method_test.go:189` `assert.Contains(t, keys(labels), ...)` dumps all 300 tokens when it fails, which is the thing the comment three lines above says the count-based assertion was chosen to avoid.
- Disclosure: to confirm fail-first I temporarily reverted the two `props.Method = methodID(...)` lines **in my own throwaway clone**, ran `make test` (both cardinality tests failed at 601 to 3,001 series / 7,501 to 37,501 lines, matching the PR body; the positive control correctly passed both before and after), then restored. Nothing was changed, committed or pushed here.
- On the `deliveryDuration` observation: confirmed — `webhooker_delivery_duration_seconds` is genuinely absent from a fresh scrape. Real, out of scope for this PR, and worth a follow-up issue against https://git.eeqj.de/sneak/webhooker/issues/209. Not required here.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #261.
methodwas recorded asr.Methodverbatim.net/httpaccepts any RFC 9110 token as a method and hands it through, so the label was bounded at nothing.The fix goes in the recorder seam #258 added for
handler— renamedroutePatternRecorder->boundedLabelRecorder, since it now bounds both request-controlled dimensions. A method the router can route is kept verbatim; anything else carries the existing(unmatched)sentinel, aliased (unmatchedMethod = unmatchedRoute) rather than respelled, so one idea does not read as two unmatched states in a scrape. Bound: 10 values.The retained set is the nine of chi's
methodMap, restated against thenet/httpconstants because chi does not export it. A token outside it can only ever produce chi's 405, so folding those together loses nothing a scrape could have used.chi.RegisterMethodwould extend the router's set; this service never calls it (verified:grep -rn RegisterMethodoverinternal/andcmd/returns nothing), and the code comment says a caller that started to must extend the switch with it.Reproduced first, then fixed
Both binaries built with
make build, run withMETRICS_USERNAME/METRICS_PASSWORDset, driven with 300 random 12-character method tokens against/, counting non-comment lines from an authenticated/metricsscrape:nextatfd5966f(unmodified)The
nextdelta of 7,525 lines matches the issue's measurement exactly (it reported 106 -> 7,631 against a warmer baseline). The branch's higher starting count is only because that instance had already been driven with real traffic for the audit below.The same reproduction at test scale,
TestMetrics_MethodSeriesCountIsFlatUnderAFloodrun against the unmodified recorder before the two-line wiring went in:Live label values from the fixed binary, showing the invented token
ZQXJ7BK2M4V9collapsed while the real methods stayed apart:Label audit
Enumerated from a live authenticated scrape of a running instance rather than from reading alone —
curl -u ... /metrics, every family and its label names extracted. That is the complete list of what/metricsexposes; nothing below was assumed present or absent.http_request_duration_seconds,http_response_size_byteshandlerroutePatternID, sentinel when unmatchedmethodmethodID(r.Method)codestrconv.Itoa(reporter.StatusCode())in go-http-metrics, whereStatusCode()reads the interceptor field written only byWriteHeadernet/httprejects anything outside 100-999http_requests_inflightserviceghmm.Config.Service, left unset inmetricsMiddlewarehttp_requests_inflighthandlerinflightHandlerconstant(all))http_request_duration_seconds,http_response_size_byteslewebhooker_*delivery metricstarget_typenormalizeTargetType, which folds anything outsideknownTargetTypestounknownwebhooker_events_received_total,webhooker_events_resubmitted_totalpromhttp_metric_handler_requests_totalcode200,500,503), observed in the scrapego_gc_duration_secondsquantilego_infoversiongo_*,process_*,promhttp_metric_handler_requests_in_flightHow each
nowas verified:code: traced the value tomiddleware.Measurein go-http-metrics, which formatsReporter.StatusCode();std.stdReporter.StatusCode()returnsresponseWriterInterceptor.statusCode, anintassigned only inWriteHeader. Nothing copies a request header or path into it. A request cannot name its own status.service:metricsMiddlewareconstructsghmm.New(ghmm.Config{Recorder: ...})and sets noService, so the field is""for the process lifetime. Confirmed empty in the live scrape above.target_type: every call site ininternal/metrics/metrics.gogoes throughnormalizeTargetType, andqueueDepthLabels/initSeriesenumerate the domain up front. Adatabase.TargetTypeoutside the four constants collapses tounknown.internal/deliveryandinternal/databasewere read for the call sites and not modified.go_*/process_*/promhttp_*families are registered byclient_golangon the default registry, which is the onepromhttp.Handler()gathers; their labels are compile-time constants of those collectors. Their presence and label sets are from the live scrape, not assumed.webhooker_delivery_duration_secondsdoes not appear in a scrape until the first delivery, becauseinitSeriesdoes not materialise it (unlike the counters and gauges). Its labels aretarget_type+le, both bounded as above. Noted as an observation, not a defect.No third unbounded dimension was found.
Tests
New, in
internal/middleware/metrics_method_test.go:TestMetrics_InventedMethodsMintOneLabelSet— 300 distinct invented tokens, exactly onemethodlabel, and no token echoed into any label. Asserts on the count rather than the set so a regression reports a number instead of dumping 300 tokens.TestMetrics_MethodSeriesCountIsFlatUnderAFlood— scrape, 300 tokens, scrape, 1,200 more, scrape; both series count and rendered exposition-line count must be flat. Lines are counted throughpromhttp.HandlerFor, i.e. the same handler/metricsmounts, because one histogram label set is ~25 lines and the line count is what the issue measured.TestMetrics_RealMethodsStayDistinct— the positive control. All nine routable methods (GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH,TRACE,CONNECT) keep their own duration series under the route pattern, one sample each.TestMetrics_MethodSentinelIsTheRouteSentinel— pins that the two sentinels are one string, so a future edit cannot introduce a second spelling.drivePathsinmetrics_test.gowas refactored onto a shareddrive/probehelper, since the probes now vary the method as well as the path; its signature and behaviour are unchanged.Verification
make checkgreen withGOFLAGS=-count=1: 580 tests pass across 20 packages,golangci-lintreports0 issues.in the Docker lint stage (summary line present, so the linter ran),fmt-checkclean. All probe processes stopped and scratch removed; ports 18961/18962 released.Out of scope and untouched: no new metrics, no changes under
internal/deliveryorinternal/database, noTODO.mdedit.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.PASS — independently reproduced and verified at
2ef52bb: on parentfd5966fmy own probe took a live scrape from 106 to 7,631 lines / 301methodvalues under 300 random 12-char tokens (601 after 600 tokens); this branch went 106 to 156 and stayed flat at 156 across a doubled flood. Positive control live: all nine chi methods keep distinct series with correctcode; 200 / 404 / 405 / 429 each landed with the rightcodeandmethod. Independent label enumeration from a live scrape found the complete label-name set to becode,handler,le,method,quantile,service,target_type,version— each traced to a bounded writer (promhttp_metric_handler_requests_totalobserved at exactly200/500/503); no third unbounded dimension.make checkgreen from a clean clone withGOFLAGS=-count=1in 1m25s: 580 tests / 20 packages / zero(cached)markers / 0 failures, lint0 issues.in the Docker stage under--no-cache-filter=lint(55.5s, not a cache hit),fmt-checkclean. CI green, mergeable, no attribution trailers,(closes #261)present.Anomalies and disclosures, none blocking:
get,PostandgEtall fold onto the sentinel. That is faithful to the router, not an accident: chi v1.5.5 upper-cases only at registration (Mux.Method), whilerouteHTTPdoes a baremethodMap[rctx.RouteMethod]lookup, so a lowercasegetcan only ever 405. Confirmed live.methodMapmatches the switch exactly — same ninenet/httpconstants in chi v1.5.5, andRegisterMethodis called nowhere in the repo or in any vendoredgo-chipackage. Residual risk of the duplicated source of truth:TestMetrics_RealMethodsStayDistinctwould catch chi dropping a method but not chi adding one, which would silently fold onto(unmatched). No clean guard exists while the map stays unexported, so I am waiving this rather than requiring it.handlerandmethodare separate label names and never merge series, and by constructionmethod="(unmatched)"implieshandler="(unmatched)"(chi 405s before it matches a pattern). So(unmatched)/(unmatched)reads as a bad method and(unmatched)/GETas a bad path — distinguishable, verified live.internal/middleware/metrics_method_test.go:189assert.Contains(t, keys(labels), ...)dumps all 300 tokens when it fails, which is the thing the comment three lines above says the count-based assertion was chosen to avoid.props.Method = methodID(...)lines in my own throwaway clone, ranmake test(both cardinality tests failed at 601 to 3,001 series / 7,501 to 37,501 lines, matching the PR body; the positive control correctly passed both before and after), then restored. Nothing was changed, committed or pushed here.deliveryDurationobservation: confirmed —webhooker_delivery_duration_secondsis genuinely absent from a fresh scrape. Real, out of scope for this PR, and worth a follow-up issue against #209. Not required here.