Skip to content

Commit b8d31c4

Browse files
committed
confighttp: route-based span naming for ServeMux
If the handler supplied to ToServer is an *http.ServeMux, use its Handler method to determine the matching pattern and use that to name the span as described at https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name If there is no matching pattern, fall back to "unknown route". Fixes open-telemetry#12468
1 parent 7ee8a2b commit b8d31c4

File tree

4 files changed

+140
-5
lines changed

4 files changed

+140
-5
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: bug_fix
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
7+
component: confighttp
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Use low-cardinality route pattern for confighttp server span names
11+
12+
# One or more tracking issues or pull requests related to the change
13+
issues: [12468]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext: |
19+
For components that route HTTP requests using a net/http.ServeMux, such as otlpreceiver,
20+
server spans will now be given low-cardinality span names based on the route pattern.
21+
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [api, user]

config/confighttp/confighttp.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,13 @@ func WithDecoder(key string, dec func(body io.ReadCloser) (io.ReadCloser, error)
410410
})
411411
}
412412

413-
// ToServer creates an http.Server from settings object.
413+
// ToServer creates an http.Server, serving requests with the given handler.
414+
//
415+
// If handler is an *http.ServeMux, then its Handler method will be used to
416+
// determine the matching route pattern to format the span name according to
417+
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name. For
418+
// backwards compatibility, if handler is NOT an *http.ServeMux, then the
419+
// span name will be the URL path.
414420
func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settings component.TelemetrySettings, handler http.Handler, opts ...ToServerOption) (*http.Server, error) {
415421
serverOpts := &toServerOptions{}
416422
serverOpts.Apply(opts...)
@@ -423,6 +429,7 @@ func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settin
423429
hss.CompressionAlgorithms = defaultCompressionAlgorithms
424430
}
425431

432+
mux, handlerIsMux := handler.(*http.ServeMux)
426433
handler = httpContentDecompressor(
427434
handler,
428435
hss.MaxRequestBodySize,
@@ -461,20 +468,38 @@ func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settin
461468
handler = responseHeadersHandler(handler, hss.ResponseHeaders)
462469
}
463470

471+
spanNameFormatter := func(_ string, r *http.Request) string {
472+
return r.URL.Path
473+
}
474+
if handlerIsMux {
475+
spanNameFormatter = func(_ string, r *http.Request) string {
476+
target := r.Pattern
477+
if target == "" {
478+
target = "unknown route"
479+
}
480+
return fmt.Sprintf("%s %s", r.Method, target)
481+
}
482+
}
483+
464484
otelOpts := append(
465485
[]otelhttp.Option{
466486
otelhttp.WithTracerProvider(settings.TracerProvider),
467487
otelhttp.WithPropagators(otel.GetTextMapPropagator()),
468-
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
469-
return r.URL.Path
470-
}),
488+
otelhttp.WithSpanNameFormatter(spanNameFormatter),
471489
otelhttp.WithMeterProvider(settings.MeterProvider),
472490
},
473491
serverOpts.OtelhttpOpts...)
474492

475493
// Enable OpenTelemetry observability plugin.
476494
handler = otelhttp.NewHandler(handler, "", otelOpts...)
477495

496+
// We need to ensure Request.Pattern is set prior to instrumentation.
497+
// This is set by ServeMux.ServeHTTP, but that won't be invoked until
498+
// after the otelhttp instrumentation.
499+
if handlerIsMux {
500+
handler = &muxPatternHandler{next: handler, mux: mux}
501+
}
502+
478503
// wrap the current handler in an interceptor that will add client.Info to the request's context
479504
handler = &clientInfoHandler{
480505
next: handler,
@@ -498,6 +523,16 @@ func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settin
498523
return server, err
499524
}
500525

526+
type muxPatternHandler struct {
527+
next http.Handler
528+
mux *http.ServeMux
529+
}
530+
531+
func (h *muxPatternHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
532+
_, r.Pattern = h.mux.Handler(r)
533+
h.next.ServeHTTP(w, r)
534+
}
535+
501536
func responseHeadersHandler(handler http.Handler, headers map[string]configopaque.String) http.Handler {
502537
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
503538
h := w.Header()

config/confighttp/confighttp_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121

2222
"github.com/stretchr/testify/assert"
2323
"github.com/stretchr/testify/require"
24+
"go.opentelemetry.io/otel/sdk/trace"
25+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
2426
"go.uber.org/zap"
2527

2628
"go.opentelemetry.io/collector/client"
@@ -1525,3 +1527,74 @@ func TestDefaultHTTPServerSettings(t *testing.T) {
15251527
assert.Equal(t, time.Duration(0), httpServerSettings.ReadTimeout)
15261528
assert.Equal(t, 1*time.Minute, httpServerSettings.ReadHeaderTimeout)
15271529
}
1530+
1531+
func TestServerTelemetry(t *testing.T) {
1532+
hss := ServerConfig{Endpoint: "localhost:0"}
1533+
exporter := tracetest.NewInMemoryExporter()
1534+
telemetry := componenttest.NewNopTelemetrySettings()
1535+
telemetry.TracerProvider = trace.NewTracerProvider(trace.WithSyncer(exporter))
1536+
1537+
nopHandler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
1538+
muxHandler := http.NewServeMux()
1539+
muxHandler.Handle("/a/{pattern}", nopHandler)
1540+
sendRequest := func(t *testing.T, url string, expectedCode int) {
1541+
t.Helper()
1542+
resp, err := http.Get(url) //nolint:gosec
1543+
require.NoError(t, err)
1544+
require.NoError(t, resp.Body.Close())
1545+
assert.Equal(t, expectedCode, resp.StatusCode)
1546+
}
1547+
1548+
t.Run("plain_handler", func(t *testing.T) {
1549+
withServer(t, hss, telemetry, nopHandler, func(_ *http.Server, url string) {
1550+
sendRequest(t, url+"/plain", http.StatusOK)
1551+
sendRequest(t, url+"/", http.StatusOK)
1552+
})
1553+
spans := exporter.GetSpans()
1554+
assert.Len(t, spans, 2)
1555+
assert.Equal(t, "/plain", spans[0].Name)
1556+
assert.Equal(t, "/", spans[1].Name)
1557+
exporter.Reset()
1558+
})
1559+
t.Run("mux", func(t *testing.T) {
1560+
withServer(t, hss, telemetry, muxHandler, func(_ *http.Server, url string) {
1561+
sendRequest(t, url+"/a/bc123", http.StatusOK)
1562+
sendRequest(t, url+"/", http.StatusNotFound)
1563+
})
1564+
spans := exporter.GetSpans()
1565+
assert.Len(t, spans, 2)
1566+
assert.Equal(t, "GET /a/{pattern}", spans[0].Name)
1567+
assert.Equal(t, "GET unknown route", spans[1].Name)
1568+
exporter.Reset()
1569+
})
1570+
}
1571+
1572+
func withServer(
1573+
t *testing.T,
1574+
cfg ServerConfig,
1575+
set component.TelemetrySettings,
1576+
h http.Handler,
1577+
f func(srv *http.Server, url string),
1578+
) {
1579+
srv, err := cfg.ToServer(context.Background(), componenttest.NewNopHost(), set, h)
1580+
require.NoError(t, err)
1581+
defer func() {
1582+
assert.NoError(t, srv.Close())
1583+
}()
1584+
1585+
lis, err := cfg.ToListener(context.Background())
1586+
require.NoError(t, err)
1587+
done := make(chan struct{})
1588+
go func() {
1589+
defer close(done)
1590+
_ = srv.Serve(lis)
1591+
}()
1592+
defer func() {
1593+
err := srv.Shutdown(context.Background())
1594+
assert.NoError(t, err)
1595+
<-done
1596+
}()
1597+
1598+
u := &url.URL{Scheme: "http", Host: lis.Addr().String()}
1599+
f(srv, u.String())
1600+
}

config/confighttp/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
go.opentelemetry.io/collector/extension/extensionauth/extensionauthtest v0.121.0
2020
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0
2121
go.opentelemetry.io/otel v1.34.0
22+
go.opentelemetry.io/otel/sdk v1.34.0
2223
go.uber.org/goleak v1.3.0
2324
go.uber.org/zap v1.27.0
2425
golang.org/x/net v0.35.0
@@ -37,7 +38,6 @@ require (
3738
go.opentelemetry.io/collector/extension v1.27.0 // indirect
3839
go.opentelemetry.io/collector/pdata v1.27.0 // indirect
3940
go.opentelemetry.io/otel/metric v1.34.0 // indirect
40-
go.opentelemetry.io/otel/sdk v1.34.0 // indirect
4141
go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect
4242
go.opentelemetry.io/otel/trace v1.34.0 // indirect
4343
go.uber.org/multierr v1.11.0 // indirect

0 commit comments

Comments
 (0)