diff --git a/contrib/net/http/get_pattern.go b/contrib/net/http/get_pattern.go new file mode 100644 index 0000000000..0b557f4322 --- /dev/null +++ b/contrib/net/http/get_pattern.go @@ -0,0 +1,26 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +//go:build go1.23 + +package http + +import ( + "net/http" +) + +// getPattern returns the pattern associated with the request or the route if no wildcard is used +func getPattern(mux *http.ServeMux, r *http.Request) string { + if r.Pattern != "" { // Will not be available if the user uses NewServeMux + return r.Pattern + } + + if mux == nil { // Will not be available if the user uses WrapHandler + return "" + } + + _, pattern := mux.Handler(r) + return pattern +} diff --git a/contrib/net/http/get_pattern_go122.go b/contrib/net/http/get_pattern_go122.go new file mode 100644 index 0000000000..959f92741f --- /dev/null +++ b/contrib/net/http/get_pattern_go122.go @@ -0,0 +1,21 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +//go:build go1.22 && !go1.23 + +package http + +import ( + "net/http" +) + +func getPattern(mux *http.ServeMux, r *http.Request) string { + if mux == nil { // Will not be available if the user uses WrapHandler + return "" + } + + _, pattern := mux.Handler(r) + return pattern +} diff --git a/contrib/net/http/http.go b/contrib/net/http/http.go index 2069e81a83..d737250a37 100644 --- a/contrib/net/http/http.go +++ b/contrib/net/http/http.go @@ -8,7 +8,6 @@ package http // import "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http" import ( "net/http" - "strings" "gopkg.in/DataDog/dd-trace-go.v1/contrib/internal/httptrace" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" @@ -50,7 +49,7 @@ func (mux *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } // get the resource associated to this request - _, pattern := mux.Handler(r) + pattern := getPattern(mux.ServeMux, r) route := patternRoute(pattern) resource := mux.cfg.resourceNamer(r) if resource == "" { @@ -65,22 +64,10 @@ func (mux *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { SpanOpts: so, Route: route, IsStatusError: mux.cfg.isStatusError, + RouteParams: patternValues(pattern, r), }) } -// patternRoute returns the route part of a go1.22 style ServeMux pattern. I.e. -// it returns "/foo" for the pattern "/foo" as well as the pattern "GET /foo". -func patternRoute(s string) string { - // Support go1.22 serve mux patterns: [METHOD ][HOST]/[PATH] - // Consider any text before a space or tab to be the method of the pattern. - // See net/http.parsePattern and the link below for more information. - // https://pkg.go.dev/net/http#hdr-Patterns - if i := strings.IndexAny(s, " \t"); i > 0 && len(s) >= i+1 { - return strings.TrimLeft(s[i+1:], " \t") - } - return s -} - // WrapHandler wraps an http.Handler with tracing using the given service and resource. // If the WithResourceNamer option is provided as part of opts, it will take precedence over the resource argument. func WrapHandler(h http.Handler, service, resource string, opts ...Option) http.Handler { @@ -104,11 +91,14 @@ func WrapHandler(h http.Handler, service, resource string, opts ...Option) http. so := make([]ddtrace.StartSpanOption, len(cfg.spanOpts), len(cfg.spanOpts)+1) copy(so, cfg.spanOpts) so = append(so, httptrace.HeaderTagsFromRequest(req, cfg.headerTags)) + pattern := getPattern(nil, req) TraceAndServe(h, w, req, &ServeConfig{ - Service: service, - Resource: resc, - FinishOpts: cfg.finishOpts, - SpanOpts: so, + Service: service, + Resource: resc, + FinishOpts: cfg.finishOpts, + SpanOpts: so, + Route: patternRoute(pattern), + RouteParams: patternValues(pattern, req), }) }) } diff --git a/contrib/net/http/http_test.go b/contrib/net/http/http_test.go index c734bbd297..2f236af299 100644 --- a/contrib/net/http/http_test.go +++ b/contrib/net/http/http_test.go @@ -285,39 +285,6 @@ func TestServeMuxUsesResourceNamer(t *testing.T) { assert.Equal("net/http", s.Tag(ext.Component)) } -func TestServeMuxGo122Patterns(t *testing.T) { - mt := mocktracer.Start() - defer mt.Stop() - - // A mux with go1.21 patterns ("/bar") and go1.22 patterns ("GET /foo") - mux := NewServeMux() - mux.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {}) - mux.HandleFunc("GET /foo", func(w http.ResponseWriter, r *http.Request) {}) - - // Try to hit both routes - barW := httptest.NewRecorder() - mux.ServeHTTP(barW, httptest.NewRequest("GET", "/bar", nil)) - fooW := httptest.NewRecorder() - mux.ServeHTTP(fooW, httptest.NewRequest("GET", "/foo", nil)) - - // Assert the number of spans - assert := assert.New(t) - spans := mt.FinishedSpans() - assert.Equal(2, len(spans)) - - // Check the /bar span - barSpan := spans[0] - assert.Equal(http.StatusOK, barW.Code) - assert.Equal("/bar", barSpan.Tag(ext.HTTPRoute)) - assert.Equal("GET /bar", barSpan.Tag(ext.ResourceName)) - - // Check the /foo span - fooSpan := spans[1] - assert.Equal(http.StatusOK, fooW.Code) - assert.Equal("/foo", fooSpan.Tag(ext.HTTPRoute)) - assert.Equal("GET /foo", fooSpan.Tag(ext.ResourceName)) -} - func TestWrapHandlerWithResourceNameNoRace(_ *testing.T) { mt := mocktracer.Start() defer mt.Stop() diff --git a/contrib/net/http/pattern.go b/contrib/net/http/pattern.go new file mode 100644 index 0000000000..98f8215d7e --- /dev/null +++ b/contrib/net/http/pattern.go @@ -0,0 +1,177 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package http + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "unicode" + + "gopkg.in/DataDog/dd-trace-go.v1/internal/log" +) + +// patternRoute returns the route part of a go1.22 style ServeMux pattern. I.e. +// it returns "/foo" for the pattern "/foo" as well as the pattern "GET /foo". +func patternRoute(s string) string { + // Support go1.22 serve mux patterns: [METHOD ][HOST]/[PATH] + // Consider any text before a space or tab to be the method of the pattern. + // See net/http.parsePattern and the link below for more information. + // https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + if i := strings.IndexAny(s, " \t"); i > 0 && len(s) >= i+1 { + return strings.TrimLeft(s[i+1:], " \t") + } + return s +} + +var patternSegmentsCache sync.Map // map[string][]string + +// patternValues return the path parameter values and names from the request. +func patternValues(pattern string, request *http.Request) map[string]string { + if pattern == "" { + return nil + } + names := getPatternNames(pattern) + res := make(map[string]string, len(names)) + for _, name := range names { + res[name] = request.PathValue(name) + } + return res +} + +func getPatternNames(pattern string) []string { + if v, ok := patternSegmentsCache.Load(pattern); ok { + if v == nil { + return nil + } + return v.([]string) + } + + segments, err := parsePatternNames(pattern) + if err != nil { + // Ignore the error: Something as gone wrong, but we are not eager to find out why. + log.Debug("contrib/net/http: failed to parse mux path pattern %q: %v", pattern, err) + // here we fallthrough instead of returning to load a nil value into the cache to avoid reparsing the pattern. + } + + patternSegmentsCache.Store(pattern, segments) + return segments +} + +// parsePatternNames returns the names of the wildcards in the pattern. +// Based on https://cs.opensource.google/go/go/+/refs/tags/go1.23.4:src/net/http/pattern.go;l=84 +// but very simplified as we know that the pattern returned must be valid or `net/http` would have panicked earlier. +// +// The pattern string's syntax is +// +// [METHOD] [HOST]/[PATH] +// +// where: +// - METHOD is an HTTP method +// - HOST is a hostname +// - PATH consists of slash-separated segments, where each segment is either +// a literal or a wildcard of the form "{name}", "{name...}", or "{$}". +// +// METHOD, HOST and PATH are all optional; that is, the string can be "/". +// If METHOD is present, it must be followed by at least one space or tab. +// Wildcard names must be valid Go identifiers. +// The "{$}" and "{name...}" wildcard must occur at the end of PATH. +// PATH may end with a '/'. +// Wildcard names in a path must be distinct. +// +// Some examples could be: +// - "/foo/{bar}" returns ["bar"] +// - "/foo/{bar}/{baz}" returns ["bar", "baz"] +// - "/foo" returns [] +func parsePatternNames(pattern string) ([]string, error) { + if len(pattern) == 0 { + return nil, errors.New("empty pattern") + } + method, rest, found := pattern, "", false + if i := strings.IndexAny(pattern, " \t"); i >= 0 { + method, rest, found = pattern[:i], strings.TrimLeft(pattern[i+1:], " \t"), true + } + if !found { + rest = method + method = "" + } + + i := strings.IndexByte(rest, '/') + if i < 0 { + return nil, errors.New("host/path missing /") + } + host := rest[:i] + rest = rest[i:] + if j := strings.IndexByte(host, '{'); j >= 0 { + return nil, errors.New("host contains '{' (missing initial '/'?)") + } + + // At this point, rest is the path. + var names []string + seenNames := make(map[string]bool) + for len(rest) > 0 { + // Invariant: rest[0] == '/'. + rest = rest[1:] + if len(rest) == 0 { + // Trailing slash. + break + } + i := strings.IndexByte(rest, '/') + if i < 0 { + i = len(rest) + } + var seg string + seg, rest = rest[:i], rest[i:] + if i := strings.IndexByte(seg, '{'); i >= 0 { + // Wildcard. + if i != 0 { + return nil, errors.New("bad wildcard segment (must start with '{')") + } + if seg[len(seg)-1] != '}' { + return nil, errors.New("bad wildcard segment (must end with '}')") + } + name := seg[1 : len(seg)-1] + if name == "$" { + if len(rest) != 0 { + return nil, errors.New("{$} not at end") + } + break + } + name, multi := strings.CutSuffix(name, "...") + if multi && len(rest) != 0 { + return nil, errors.New("{...} wildcard not at end") + } + if name == "" { + return nil, errors.New("empty wildcard name") + } + if !isValidWildcardName(name) { + return nil, fmt.Errorf("bad wildcard name %q", name) + } + if seenNames[name] { + return nil, fmt.Errorf("duplicate wildcard name %q", name) + } + seenNames[name] = true + names = append(names, name) + } + } + + return names, nil +} + +func isValidWildcardName(s string) bool { + if s == "" { + return false + } + // Valid Go identifier. + for i, c := range s { + if !unicode.IsLetter(c) && c != '_' && (i == 0 || !unicode.IsDigit(c)) { + return false + } + } + return true +} diff --git a/contrib/net/http/pattern_test.go b/contrib/net/http/pattern_test.go new file mode 100644 index 0000000000..4c8fa27407 --- /dev/null +++ b/contrib/net/http/pattern_test.go @@ -0,0 +1,166 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package http + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" +) + +func TestPathParams(t *testing.T) { + for _, tt := range []struct { + name string + pattern string + url string + expected map[string]string + }{ + { + name: "simple", + pattern: "/foo/{bar}", + url: "/foo/123", + expected: map[string]string{"bar": "123"}, + }, + { + name: "multiple", + pattern: "/foo/{bar}/{baz}", + url: "/foo/123/456", + expected: map[string]string{"bar": "123", "baz": "456"}, + }, + { + name: "nested", + pattern: "/foo/{bar}/baz/{qux}", + url: "/foo/123/baz/456", + expected: map[string]string{"bar": "123", "qux": "456"}, + }, + { + name: "empty", + pattern: "/foo/{bar}", + url: "/foo/", + expected: map[string]string{"bar": ""}, + }, + { + name: "http method", + pattern: "GET /foo/{bar}", + url: "/foo/123", + expected: map[string]string{"bar": "123"}, + }, + { + name: "host", + pattern: "example.com/foo/{bar}", + url: "http://example.com/foo/123", + expected: map[string]string{"bar": "123"}, + }, + { + name: "host and method", + pattern: "GET example.com/foo/{bar}", + url: "http://example.com/foo/123", + expected: map[string]string{"bar": "123"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + mux := NewServeMux() + mux.HandleFunc(tt.pattern, func(_ http.ResponseWriter, r *http.Request) { + _, pattern := mux.Handler(r) + params := patternValues(pattern, r) + assert.Equal(t, tt.expected, params) + }) + + r := httptest.NewRequest("GET", tt.url, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + }) + } +} + +func TestPatternNames(t *testing.T) { + tests := []struct { + pattern string + expected []string + err bool + }{ + {"/foo/{bar}", []string{"bar"}, false}, + {"/foo/{bar}/{baz}", []string{"bar", "baz"}, false}, + {"/foo/{bar}/{bar}", nil, true}, + {"/foo/{bar}...", nil, true}, + {"/foo/{bar}.../baz", nil, true}, + {"/foo/{bar}/{baz}...", nil, true}, + {"/foo/{bar", nil, true}, + {"/foo/{bar{baz}}", nil, true}, + {"/foo/{}", nil, true}, + {"{}", nil, true}, + {"GET /foo/{bar}", []string{"bar"}, false}, + {"POST /foo/{bar}/{baz}", []string{"bar", "baz"}, false}, + {"PUT /foo/{bar}/{bar}", nil, true}, + {"DELETE /foo/{bar}...", nil, true}, + {"PATCH /foo/{bar}.../baz", nil, true}, + {"OPTIONS /foo/{bar}/{baz}...", nil, true}, + {"GET /foo/{bar", nil, true}, + {"POST /foo/{bar{baz}}", nil, true}, + {"DELETE /foo/{}", nil, true}, + {"OPTIONS {}", nil, true}, + {"GET example.com/foo/{bar}", []string{"bar"}, false}, + {"POST example.com/foo/{bar}/{baz}", []string{"bar", "baz"}, false}, + {"PUT example.com/foo/{bar}/{bar}", nil, true}, + {"DELETE example.com/foo/{bar}...", nil, true}, + {"PATCH example.com/foo/{bar}.../baz", nil, true}, + {"OPTIONS example.com/foo/{bar}/{baz}...", nil, true}, + {"GET example.com/foo/{bar", nil, true}, + {"POST example.com/foo/{bar{baz}}", nil, true}, + {"DELETE example.com/foo/{}", nil, true}, + {"OPTIONS example.com/{}", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + names, err := parsePatternNames(tt.pattern) + if tt.err { + assert.Error(t, err) + assert.Nil(t, names) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, names) + } + }) + } +} + +func TestServeMuxGo122Patterns(t *testing.T) { + mt := mocktracer.Start() + defer mt.Stop() + + // A mux with go1.21 patterns ("/bar") and go1.22 patterns ("GET /foo") + mux := NewServeMux() + mux.HandleFunc("/bar", func(_ http.ResponseWriter, _ *http.Request) {}) + mux.HandleFunc("GET /foo", func(_ http.ResponseWriter, _ *http.Request) {}) + + // Try to hit both routes + barW := httptest.NewRecorder() + mux.ServeHTTP(barW, httptest.NewRequest("GET", "/bar", nil)) + fooW := httptest.NewRecorder() + mux.ServeHTTP(fooW, httptest.NewRequest("GET", "/foo", nil)) + + // Assert the number of spans + assert := assert.New(t) + spans := mt.FinishedSpans() + assert.Equal(2, len(spans)) + + // Check the /bar span + barSpan := spans[0] + assert.Equal(http.StatusOK, barW.Code) + assert.Equal("/bar", barSpan.Tag(ext.HTTPRoute)) + assert.Equal("GET /bar", barSpan.Tag(ext.ResourceName)) + + // Check the /foo span + fooSpan := spans[1] + assert.Equal(http.StatusOK, fooW.Code) + assert.Equal("/foo", fooSpan.Tag(ext.HTTPRoute)) + assert.Equal("GET /foo", fooSpan.Tag(ext.ResourceName)) +}