From 6ca43e26d7a6bd8f57e2d0c1185584f34a9f2bf1 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 01:45:21 +0000 Subject: [PATCH] Label request metrics by route, not by path (closes #254) The metrics middleware gave the handler label each request's path, so every new path added series that were never removed, and /metrics listed encrypted URL tokens. The label is now the pattern of the chi route the request matches, or "unmatched" for one that matches none. The metrics library takes the label before calling the handler, so the middleware asks the router with chi's Find which route matches. Model: opus-5-5 --- README.md | 5 ++++- TODO.md | 9 ++++++++ internal/middleware/middleware.go | 36 +++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 609ad9e..221b6be 100644 --- a/README.md +++ b/README.md @@ -639,7 +639,10 @@ See `configs/config.example.yml` for all options with defaults. - **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics (duration, response size, requests in flight) and the Go runtime and process metrics; requests are measured and `/metrics` is served only when - `metrics.username` and `metrics.password` are set + `metrics.username` and `metrics.password` are set. A request's `handler` label + is the pattern of the route it matches, such as `/v1/image/*`, never its path. + Requests that match no route share the label `unmatched`: those the router + answers 404 or 405, and CORS preflights - **Logging**: stdlib slog ## Entrypoints diff --git a/TODO.md b/TODO.md index d1ccf51..a67db28 100644 --- a/TODO.md +++ b/TODO.md @@ -41,6 +41,15 @@ P2: security: per-origin rate limiting not in `allowlist.IsAllowed`, which also matches `referer_blocklist`, where a `Referer` is blocked whatever its port. A URL is signed over its host as it writes it, in lower case, with any IPv6 brackets, `:` and port kept. +- 2026-10-15 the request metrics are labelled by route, not by path (closes + #254): their `handler` label was each request's path, so every new path, a 404 + or an encrypted URL, added series to the Prometheus registry that were never + removed, and `/metrics` listed every encrypted URL's token. The label is now + the pattern of the chi route the request matches, such as `/v1/image/*`, or + `unmatched` for a request that matches none: one the router answers 404 or + 405, or a CORS preflight. The metrics library takes the label before the + request is routed, so the metrics middleware asks the router which route + matches. - 2026-10-15 a `:` in the source path or query no longer moves where the path ends and the query begins (closes #247): the signed data and the cache key joined host, path and query with `:` and no escaping, so a URL signed for diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7b433c7..274ac50 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -11,6 +11,7 @@ import ( "time" basicauth "github.com/99designs/basicauth-go" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/httprate" @@ -214,7 +215,11 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler { }) } -// Metrics returns a Prometheus metrics middleware. +// Metrics returns a Prometheus metrics middleware. Its handler label is the +// pattern of the chi route a request matches, such as /v1/image/*, or +// "unmatched" for a request that matches none, so the routes fix how many +// values the label has. It is never the path, with which any client could +// add series to the registry without limit. func (s *Middleware) Metrics() func(http.Handler) http.Handler { mdlw := ghmm.New(ghmm.Config{ Recorder: metrics.NewRecorder(metrics.Config{ @@ -223,10 +228,37 @@ func (s *Middleware) Metrics() func(http.Handler) http.Handler { }) return func(next http.Handler) http.Handler { - return std.Handler("", mdlw, next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + std.Handler(routePattern(r), mdlw, next).ServeHTTP(w, r) + }) } } +// routePattern returns the pattern of the chi route r matches, or +// "unmatched" when it matches none or is not served by a chi router. The +// metrics library takes the label before the request is routed, so the +// router is asked which route matches, with the path it routes on: +// r.URL.RawPath when set, else r.URL.Path. +func routePattern(r *http.Request) string { + pattern := "" + + rctx := chi.RouteContext(r.Context()) + if rctx != nil { + path := r.URL.RawPath + if path == "" { + path = r.URL.Path + } + + pattern = rctx.Routes.Find(chi.NewRouteContext(), r.Method, path) + } + + if pattern == "" { + return "unmatched" + } + + return pattern +} + // MetricsAuth returns a basic auth middleware for the metrics endpoint. func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { return basicauth.New(