From bc6464a3c5de3102b2e110821c6b9e3d07d0145f Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 19:01:28 +0000 Subject: [PATCH] Label request metrics with standard HTTP methods only (closes #274) The method label was each request's method as sent, and Go's HTTP server accepts any method name, so made-up methods added series to the registry that were never removed. It is now the method only for GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT and TRACE, and "other" for any other. go-http-metrics reads the method from the request it is handed, so the middleware hands it a copy carrying the label and serves the next handler the original request. Model: opus-5-5 --- README.md | 4 +++- TODO.md | 9 +++++++++ internal/middleware/middleware.go | 33 +++++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 87d3838..13a6a3b 100644 --- a/README.md +++ b/README.md @@ -685,7 +685,9 @@ See `configs/config.example.yml` for all options with defaults. `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 + answers 404 or 405, and CORS preflights. A request's `method` label is its + method when that is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, + `OPTIONS`, `CONNECT` or `TRACE`, and `other` for any other method - **Logging**: stdlib slog ## Entrypoints diff --git a/TODO.md b/TODO.md index 724a404..3c33482 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,15 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 the request metrics record any method that is not a standard one as + `other` (closes #274): their `method` label was each request's method as sent, + and Go's HTTP server accepts any method name, so a client sending made-up + methods added series to the Prometheus registry that were never removed. The + label is now the request's method only for `GET`, `HEAD`, `POST`, `PUT`, + `PATCH`, `DELETE`, `OPTIONS`, `CONNECT` and `TRACE`. The metrics library reads + the method from the request it is handed, so the metrics middleware hands it a + copy of the request carrying the label; the handlers still see the method the + client sent. - 2026-10-15 logging out ends every login session (closes #256): a login session was a cookie with nothing kept on the server, so `/logout` removed it from one browser and a copy of it kept working for its 30 days. Every login is made diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index a35c23e..4053a0a 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -220,7 +220,10 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler { // 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. +// add series to the registry without limit. Its method label is the +// request's method when that is a standard one, such as GET, and "other" +// for any other: Go's HTTP server accepts any method name, so a label per +// method would let a client add series the same way. func (s *Middleware) Metrics() func(http.Handler) http.Handler { mdlw := ghmm.New(ghmm.Config{ Recorder: metrics.NewRecorder(metrics.Config{ @@ -230,7 +233,19 @@ func (s *Middleware) Metrics() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - std.Handler(routePattern(r), mdlw, next).ServeHTTP(w, r) + // The metrics library takes the method label from the request + // it is handed, so it is handed a shallow copy of r whose + // method is the label. next is served r itself, through the + // response writer the library wraps to see the status and size. + labelled := r.WithContext(r.Context()) + labelled.Method = methodLabel(r.Method) + + serveNext := http.HandlerFunc( + func(wrapped http.ResponseWriter, _ *http.Request) { + next.ServeHTTP(wrapped, r) + }) + + std.Handler(routePattern(r), mdlw, serveNext).ServeHTTP(w, labelled) }) } } @@ -275,6 +290,20 @@ func routePattern(r *http.Request) string { return pattern } +// methodLabel returns method when it is one of the standard methods net/http +// names: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT and TRACE. +// For any other method it returns "other". +func methodLabel(method string) string { + switch method { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodOptions, + http.MethodConnect, http.MethodTrace: + return method + default: + return "other" + } +} + // MetricsAuth returns a basic auth middleware for the metrics endpoint. func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { return basicauth.New(