fix(telemetry): record route patterns instead of request paths on HTTP metrics (#12557)

# Which Problems Are Solved

- The `uri` label of `http.server.request_count` and
`http.server.return_code_counter` holds the concrete request path, so
every object ID served over a REST path becomes its own metric series.
On our v4.16.2 instance `/debug/metrics` exposes 8,000 series, 4,467 of
which (56%) belong to `http_server_return_code_counter_total` alone,
spread over 4,390 distinct `uri` values such as
`/v2/sessions/385063742120926058`. The set only grows with the number of
objects the installation has served.
- Paths matching no route are recorded verbatim, so unauthenticated
scanner traffic (`/wp-json/`, `/login.php`, …) keeps adding series.
- The grpc-gateway answers `405 Method Not Allowed` without going
through the configured `errorHandler`, so those responses never reported
a route pattern, not even before the regression.

This is a regression, first shipped in v4.11.0; v4.10.1 is unaffected.
#9286 introduced the mechanism, #9523 extended it to unknown paths, and
#11435 removed it while reorganising the middleware packages:

> Removed setting of URI to context in metric middleware. There were
only setters and no getters. (Unused value)

The getter was `*recorder.RequestURI` in `RegisterRequestCounter` /
`RegisterRequestCodeCounter`, which the same PR replaced with
`baseURI(r)`. Both ends went at once, so nothing failed.

# How the Problems Are Solved

- `metrics.WithRequestURIPattern` / `metrics.SetRequestURIPattern` are
back, and `metrics.RequestURI` is now the single place deciding what to
label with: the pattern a router reported, or the requested path if none
did. `UnknownPath` moved to the metrics package so every surface shares
one constant.
- The HTTP metrics middleware prepares the context before passing the
request on, which is the half that got dropped.
- `setRequestURIPattern` in the grpc-gateway reports the pattern to
metrics again, not only to tracing, and is now also called on the `405`
branch.
- Requests routed by chi (the OIDC endpoints) fall back to the pattern
chi matched. That covers the RFC 7592 client configuration routes added
in #12315 (`/oauth/v2/register/{client_id}`), which are templated on the
client ID, and collapses unknown paths below the OIDC prefixes to
`UNKNOWN_PATH` as well.

Against the metrics dump from our instance,
`http_server_return_code_counter_total` drops from 4,467 series to
roughly 35, and the endpoint as a whole from 8,000 to about 3,570.

# Additional Changes

- Regression tests, which were missing and are the reason the revert
went unnoticed:
- `metrics`: resolution of the `uri` label, including that a pattern set
on a derived context reaches the middleware.
- `gateway`: every way the gateway can answer — success, error,
unroutable path, wrong method.
- `middleware`: both routing styles, i.e. a router reporting its own
pattern and a chi routed request.
- A doc comment on `SetRequestURIPattern` explaining that it is written
by routers and read back by the middleware through the context, so it
does not read as an unused setter again.

# Additional Context

- Closes #12556
- Restores #9286 and #9523
- Regressed in #11435

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Harsha Reddy
2026-08-07 17:23:56 +02:00
committed by GitHub
co-authored by Cursor
parent 9fa0cad9d9
commit 83854e80e0
6 changed files with 362 additions and 7 deletions
@@ -1,6 +1,7 @@
package metrics
import (
"context"
"net/http"
"strings"
@@ -17,6 +18,11 @@ const (
Method = "method"
URI = "uri"
ReturnCode = "return_code"
// UnknownPath is recorded as uri for requests that did not match any route.
// Recording the requested path instead would allow anyone to create an unbounded
// number of metric series by calling arbitrary paths.
UnknownPath = "UNKNOWN_PATH"
)
type MetricType int32
@@ -31,9 +37,44 @@ type StatusRecorder interface {
Status() int
}
type requestURIKey struct{}
// WithRequestURIPattern returns a context that carries the uri label of the HTTP metrics
// recorded for the current request. The metrics middleware must call this before passing
// the request on, so that the router serving it can substitute its route pattern through
// [SetRequestURIPattern].
func WithRequestURIPattern(ctx context.Context) context.Context {
var pattern string
return context.WithValue(ctx, requestURIKey{}, &pattern)
}
// SetRequestURIPattern records pattern as the uri label of the current request's HTTP
// metrics, e.g. "/v2/sessions/{session_id}" instead of "/v2/sessions/385063742120926058".
//
// Routers that serve templated paths must call this for every response they produce,
// including errors. Without it every resource ID gets its own metric series and the
// number of series only grows with the number of objects in the installation.
func SetRequestURIPattern(ctx context.Context, pattern string) {
uri, ok := ctx.Value(requestURIKey{}).(*string)
if !ok {
return
}
*uri = pattern
}
// RequestURI returns the uri label to record for r: the route pattern reported by the
// router through [SetRequestURIPattern], or the requested path if the router did not
// report one.
func RequestURI(r *http.Request) string {
if pattern, ok := r.Context().Value(requestURIKey{}).(*string); ok && *pattern != "" {
return *pattern
}
return strings.Split(r.RequestURI, "?")[0]
}
func RegisterRequestCounter(recorder StatusRecorder, r *http.Request) {
var labels = map[string]attribute.Value{
URI: attribute.StringValue(baseURI(r)),
URI: attribute.StringValue(RequestURI(r)),
Method: attribute.StringValue(r.Method),
}
RegisterCounter(RequestCounter, RequestCountDescription)
@@ -47,14 +88,10 @@ func RegisterTotalRequestCounter(r *http.Request) {
func RegisterRequestCodeCounter(recorder StatusRecorder, r *http.Request) {
var labels = map[string]attribute.Value{
URI: attribute.StringValue(baseURI(r)),
URI: attribute.StringValue(RequestURI(r)),
Method: attribute.StringValue(r.Method),
ReturnCode: attribute.IntValue(recorder.Status()),
}
RegisterCounter(ReturnCodeCounter, ReturnCodeCounterDescription)
AddCount(r.Context(), ReturnCodeCounter, 1, labels)
}
func baseURI(r *http.Request) string {
return strings.Split(r.RequestURI, "?")[0]
}
@@ -0,0 +1,91 @@
package metrics
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequestURI(t *testing.T) {
tests := []struct {
name string
requestURI string
// prepare returns the context the request is served with.
prepare func(context.Context) context.Context
want string
}{
{
name: "no pattern support, path is recorded",
requestURI: "/oauth/v2/token",
prepare: func(ctx context.Context) context.Context { return ctx },
want: "/oauth/v2/token",
},
{
name: "no pattern support, query is stripped",
requestURI: "/oauth/v2/authorize?client_id=1234&state=abcd",
prepare: func(ctx context.Context) context.Context { return ctx },
want: "/oauth/v2/authorize",
},
{
name: "pattern supported but not set, path is recorded",
requestURI: "/oauth/v2/token",
prepare: WithRequestURIPattern,
want: "/oauth/v2/token",
},
{
name: "pattern set, pattern is recorded instead of the object id",
requestURI: "/v2/sessions/385063742120926058",
prepare: func(ctx context.Context) context.Context {
ctx = WithRequestURIPattern(ctx)
SetRequestURIPattern(ctx, "/v2/sessions/{session_id}")
return ctx
},
want: "/v2/sessions/{session_id}",
},
{
name: "unknown path, no path is recorded",
requestURI: "/junk",
prepare: func(ctx context.Context) context.Context {
ctx = WithRequestURIPattern(ctx)
SetRequestURIPattern(ctx, UnknownPath)
return ctx
},
want: UnknownPath,
},
{
name: "pattern set without context support, path is recorded",
requestURI: "/v2/sessions/385063742120926058",
prepare: func(ctx context.Context) context.Context {
SetRequestURIPattern(ctx, "/v2/sessions/{session_id}")
return ctx
},
want: "/v2/sessions/385063742120926058",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, tt.requestURI, nil)
r = r.WithContext(tt.prepare(r.Context()))
assert.Equal(t, tt.want, RequestURI(r))
})
}
}
// TestSetRequestURIPattern_derivedContext ensures the pattern set by a handler is visible
// to the middleware that seeded the context, even though the handler received a derived
// context. Routers such as the grpc-gateway only ever report the pattern on their own
// context, so a value copied by [context.WithValue] would not reach the caller.
func TestSetRequestURIPattern_derivedContext(t *testing.T) {
r := httptest.NewRequest(http.MethodDelete, "/v2/sessions/385063742120926058", nil)
r = r.WithContext(WithRequestURIPattern(r.Context()))
type unrelatedKey struct{}
derived := context.WithValue(r.Context(), unrelatedKey{}, "value")
SetRequestURIPattern(derived, "/v2/sessions/{session_id}")
assert.Equal(t, "/v2/sessions/{session_id}", RequestURI(r))
}