diff --git a/http/mux.go b/http/mux.go index 068a8a83bf..a2d9421a48 100644 --- a/http/mux.go +++ b/http/mux.go @@ -82,11 +82,19 @@ type ( ) // NewMuxer returns a Muxer implementation based on a Chi router. +// +// The returned muxer sets r.Pattern (Go 1.22+) on every dispatched request +// before middlewares run. This allows observability middleware such as +// otelhttp to read the matched route from r.Pattern for span attributes and +// metrics. To take advantage of this, register otelhttp as a mux middleware +// rather than wrapping the mux externally: +// +// mux := goahttp.NewMuxer() +// mux.Use(otelhttp.NewMiddleware("service")) func NewMuxer() ResolverMuxer { return &mux{ - Router: chi.NewRouter(), - wildcards: make(map[string]string), - middlewares: nil, + Router: chi.NewRouter(), + wildcards: make(map[string]string), } } @@ -129,6 +137,18 @@ func (m *mux) Handle(method, pattern string, handler http.HandlerFunc) { })) } +// ServeHTTP resolves the matched route and sets r.Pattern before dispatching +// the request through chi's middleware chain and handler. This ensures that +// middlewares registered via Use() — such as otelhttp.NewMiddleware — can +// read r.Pattern to tag spans and metrics with the http.route attribute. +func (m *mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rctx := chi.NewRouteContext() + if m.Match(rctx, r.Method, r.URL.Path) { + r.Pattern = r.Method + " " + m.resolveWildcard(r.Method, rctx.RoutePattern()) + } + m.Router.ServeHTTP(w, r) +} + // Vars extracts the path variables from the request context. func (m *mux) Vars(r *http.Request) map[string]string { ctx := m.ensureContext(r) diff --git a/http/mux_test.go b/http/mux_test.go index 324f7ee06c..1f1d2a7fe3 100644 --- a/http/mux_test.go +++ b/http/mux_test.go @@ -193,6 +193,63 @@ func TestRequestPattern(t *testing.T) { } } +// TestRequestPatternInMiddleware verifies that r.Pattern is set by the +// built-in middleware before user-registered middlewares run. This is +// critical for observability middleware (e.g., otelhttp) that reads +// r.Pattern to set the http.route span attribute at span-start time. +func TestRequestPatternInMiddleware(t *testing.T) { + cases := []struct { + Name string + Method string + Pattern string + URL string + Expected string + }{ + { + Name: "simple", + Method: "GET", + Pattern: "/users", + URL: "/users", + Expected: "GET /users", + }, + { + Name: "with segment", + Method: "POST", + Pattern: "/users/{id}", + URL: "/users/123", + Expected: "POST /users/{id}", + }, + { + Name: "with wildcard", + Method: "GET", + Pattern: "/files/{*path}", + URL: "/files/a/b/c", + Expected: "GET /files/{*path}", + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + var middlewareCalled bool + mux := NewMuxer() + // Register a user middleware that reads r.Pattern — + // simulating otelhttp.NewMiddleware. + mux.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, c.Expected, r.Pattern) + middlewareCalled = true + next.ServeHTTP(w, r) + }) + }) + mux.Handle(c.Method, c.Pattern, func(_ http.ResponseWriter, _ *http.Request) {}) + req, _ := http.NewRequest(c.Method, c.URL, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.True(t, middlewareCalled) + }) + } +} + func TestResolvePattern(t *testing.T) { cases := []struct { Name string