Skip to content

Commit

Permalink
contrib/net/http: support for path params from go 1.22 proposal (#3079)
Browse files Browse the repository at this point in the history
  • Loading branch information
eliottness authored Jan 15, 2025
1 parent 471d723 commit c8fce21
Show file tree
Hide file tree
Showing 6 changed files with 399 additions and 52 deletions.
26 changes: 26 additions & 0 deletions contrib/net/http/get_pattern.go
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions contrib/net/http/get_pattern_go122.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 9 additions & 19 deletions contrib/net/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand All @@ -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),
})
})
}
33 changes: 0 additions & 33 deletions contrib/net/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
177 changes: 177 additions & 0 deletions contrib/net/http/pattern.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit c8fce21

Please sign in to comment.