Skip to content

Commit a769648

Browse files
committed
tracing: name HTTP spans as "{method} {route}" per OTel semconv
OPA emits HTTP spans named after the Prometheus handler label (e.g. "v1/data") on the server side and "HTTP {METHOD}" on the client side. The OpenTelemetry HTTP semantic conventions specify span names of the form "{method} {route}" / "{method} {target}", which makes traces easier to read in tools like Jaeger. Install default span-name formatters on both the otelhttp Handler and Transport produced by features/tracing: - server: "{METHOD} {operation}" where operation is the route label OPA already supplies to NewHandler (e.g. "POST v1/data", "GET health"). - client: "{METHOD} {url.path}" with a "{METHOD}" fallback when no path is available, replacing otelhttp's legacy "HTTP {METHOD}". The defaults are prepended to the converted otelhttp options so that any user-supplied WithSpanNameFormatter passed via tracing.Options still takes precedence (otelhttp applies options in order, last write wins). Existing assertions in the distributed-tracing e2e test are updated to match the new names. New unit tests in v1/features/tracing cover the formatter logic directly and end-to-end through otelhttp, including the user-override case. Closes #8612 Signed-off-by: ChrisJr404 <chris@hacknow.com>
1 parent 8750603 commit a769648

3 files changed

Lines changed: 232 additions & 7 deletions

File tree

v1/features/tracing/tracing.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,56 @@ func init() {
1818
type factory struct{}
1919

2020
func (*factory) NewTransport(tr http.RoundTripper, opts pkg_tracing.Options) http.RoundTripper {
21-
return otelhttp.NewTransport(tr, convertOpts(opts)...)
21+
return otelhttp.NewTransport(tr, withDefaultSpanNameFormatter(opts, clientSpanName)...)
2222
}
2323

2424
func (*factory) NewHandler(f http.Handler, label string, opts pkg_tracing.Options) http.Handler {
25-
return otelhttp.NewHandler(f, label, convertOpts(opts)...)
25+
return otelhttp.NewHandler(f, label, withDefaultSpanNameFormatter(opts, serverSpanName)...)
26+
}
27+
28+
// serverSpanName names server-side HTTP spans as "{method} {operation}", where
29+
// `operation` is the route label supplied to NewHandler (for OPA, e.g.
30+
// "v1/data"). This follows the OpenTelemetry HTTP semantic conventions, which
31+
// require the HTTP method as the first token of the span name:
32+
// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name
33+
func serverSpanName(operation string, r *http.Request) string {
34+
if r == nil || r.Method == "" {
35+
return operation
36+
}
37+
if operation == "" {
38+
return r.Method
39+
}
40+
return r.Method + " " + operation
41+
}
42+
43+
// clientSpanName names client-side HTTP spans as "{method} {url.path}" where a
44+
// path is available, falling back to "{method}" otherwise. The OpenTelemetry
45+
// HTTP semantic conventions prefer "{method} {target}" over the legacy
46+
// "HTTP {method}" form used by otelhttp's default formatter.
47+
func clientSpanName(_ string, r *http.Request) string {
48+
if r == nil {
49+
return "HTTP"
50+
}
51+
method := r.Method
52+
if method == "" {
53+
method = "HTTP"
54+
}
55+
if r.URL != nil && r.URL.Path != "" {
56+
return method + " " + r.URL.Path
57+
}
58+
return method
59+
}
60+
61+
// withDefaultSpanNameFormatter prepends the provided span name formatter to the
62+
// converted otelhttp options. Because otelhttp applies options in order with
63+
// last-write-wins semantics, any user-supplied WithSpanNameFormatter passed
64+
// through pkg_tracing.Options still takes precedence.
65+
func withDefaultSpanNameFormatter(opts pkg_tracing.Options, fn func(string, *http.Request) string) []otelhttp.Option {
66+
converted := convertOpts(opts)
67+
out := make([]otelhttp.Option, 0, len(converted)+1)
68+
out = append(out, otelhttp.WithSpanNameFormatter(fn))
69+
out = append(out, converted...)
70+
return out
2671
}
2772

2873
func convertOpts(opts pkg_tracing.Options) []otelhttp.Option {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright 2026 The OPA Authors. All rights reserved.
2+
// Use of this source code is governed by an Apache2
3+
// license that can be found in the LICENSE file.
4+
5+
package tracing
6+
7+
import (
8+
"context"
9+
"net/http"
10+
"net/http/httptest"
11+
"net/url"
12+
"testing"
13+
14+
pkg_tracing "github.com/open-policy-agent/opa/v1/tracing"
15+
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
16+
"go.opentelemetry.io/otel/sdk/trace"
17+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
18+
)
19+
20+
func TestServerSpanNameFormatter(t *testing.T) {
21+
cases := []struct {
22+
name string
23+
method string
24+
operation string
25+
want string
26+
}{
27+
{"GET v1/data", "GET", "v1/data", "GET v1/data"},
28+
{"POST v1/data", "POST", "v1/data", "POST v1/data"},
29+
{"DELETE v1/policies", "DELETE", "v1/policies", "DELETE v1/policies"},
30+
{"empty operation", "GET", "", "GET"},
31+
{"empty method", "", "v1/data", "v1/data"},
32+
{"nil request", "", "v1/data", "v1/data"},
33+
}
34+
35+
for _, tc := range cases {
36+
t.Run(tc.name, func(t *testing.T) {
37+
var r *http.Request
38+
if tc.name != "nil request" {
39+
r = &http.Request{Method: tc.method, URL: &url.URL{Path: "/whatever"}}
40+
}
41+
if got := serverSpanName(tc.operation, r); got != tc.want {
42+
t.Fatalf("serverSpanName(%q, %+v) = %q, want %q", tc.operation, r, got, tc.want)
43+
}
44+
})
45+
}
46+
}
47+
48+
func TestClientSpanNameFormatter(t *testing.T) {
49+
cases := []struct {
50+
name string
51+
method string
52+
path string
53+
want string
54+
}{
55+
{"GET with path", "GET", "/v1/data", "GET /v1/data"},
56+
{"POST with path", "POST", "/v1/logs", "POST /v1/logs"},
57+
{"GET no path", "GET", "", "GET"},
58+
{"empty method with path", "", "/health", "HTTP /health"},
59+
}
60+
61+
for _, tc := range cases {
62+
t.Run(tc.name, func(t *testing.T) {
63+
r := &http.Request{Method: tc.method, URL: &url.URL{Path: tc.path}}
64+
if got := clientSpanName("", r); got != tc.want {
65+
t.Fatalf("clientSpanName(%q, path=%q) = %q, want %q", tc.method, tc.path, got, tc.want)
66+
}
67+
})
68+
}
69+
70+
t.Run("nil request", func(t *testing.T) {
71+
if got := clientSpanName("", nil); got != "HTTP" {
72+
t.Fatalf("clientSpanName(nil) = %q, want %q", got, "HTTP")
73+
}
74+
})
75+
}
76+
77+
// TestHandlerSpanNameEndToEnd asserts that the handler factory wraps
78+
// otelhttp.NewHandler such that the recorded server span is named
79+
// "{method} {operation}" per OpenTelemetry HTTP semantic conventions.
80+
func TestHandlerSpanNameEndToEnd(t *testing.T) {
81+
exporter := tracetest.NewInMemoryExporter()
82+
tp := trace.NewTracerProvider(trace.WithSpanProcessor(trace.NewSimpleSpanProcessor(exporter)))
83+
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
84+
85+
opts := pkg_tracing.NewOptions(otelhttp.WithTracerProvider(tp))
86+
87+
f := &factory{}
88+
h := f.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
89+
w.WriteHeader(http.StatusOK)
90+
}), "v1/data", opts)
91+
92+
srv := httptest.NewServer(h)
93+
t.Cleanup(srv.Close)
94+
95+
resp, err := http.Post(srv.URL+"/v1/data/foo", "application/json", nil)
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
_ = resp.Body.Close()
100+
101+
spans := exporter.GetSpans()
102+
if got, want := len(spans), 1; got != want {
103+
t.Fatalf("got %d span(s), want %d", got, want)
104+
}
105+
if got, want := spans[0].Name, "POST v1/data"; got != want {
106+
t.Fatalf("span name = %q, want %q", got, want)
107+
}
108+
}
109+
110+
// TestUserSpanNameFormatterOverrides asserts that any user-supplied
111+
// WithSpanNameFormatter in the Options still wins over OPA's default.
112+
func TestUserSpanNameFormatterOverrides(t *testing.T) {
113+
exporter := tracetest.NewInMemoryExporter()
114+
tp := trace.NewTracerProvider(trace.WithSpanProcessor(trace.NewSimpleSpanProcessor(exporter)))
115+
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
116+
117+
custom := func(string, *http.Request) string { return "custom-name" }
118+
opts := pkg_tracing.NewOptions(
119+
otelhttp.WithTracerProvider(tp),
120+
otelhttp.WithSpanNameFormatter(custom),
121+
)
122+
123+
f := &factory{}
124+
h := f.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
125+
w.WriteHeader(http.StatusOK)
126+
}), "v1/data", opts)
127+
128+
srv := httptest.NewServer(h)
129+
t.Cleanup(srv.Close)
130+
131+
resp, err := http.Get(srv.URL + "/anything")
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
_ = resp.Body.Close()
136+
137+
spans := exporter.GetSpans()
138+
if got, want := len(spans), 1; got != want {
139+
t.Fatalf("got %d span(s), want %d", got, want)
140+
}
141+
if got, want := spans[0].Name, "custom-name"; got != want {
142+
t.Fatalf("span name = %q, want %q", got, want)
143+
}
144+
}
145+
146+
// TestTransportSpanNameEndToEnd asserts that the transport factory names
147+
// outbound HTTP client spans as "{method} {path}".
148+
func TestTransportSpanNameEndToEnd(t *testing.T) {
149+
exporter := tracetest.NewInMemoryExporter()
150+
tp := trace.NewTracerProvider(trace.WithSpanProcessor(trace.NewSimpleSpanProcessor(exporter)))
151+
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
152+
153+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
154+
w.WriteHeader(http.StatusOK)
155+
}))
156+
t.Cleanup(upstream.Close)
157+
158+
opts := pkg_tracing.NewOptions(otelhttp.WithTracerProvider(tp))
159+
160+
f := &factory{}
161+
client := &http.Client{Transport: f.NewTransport(http.DefaultTransport, opts)}
162+
163+
req, err := http.NewRequest(http.MethodPost, upstream.URL+"/v1/logs", nil)
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
resp, err := client.Do(req)
168+
if err != nil {
169+
t.Fatal(err)
170+
}
171+
_ = resp.Body.Close()
172+
173+
spans := exporter.GetSpans()
174+
if got, want := len(spans), 1; got != want {
175+
t.Fatalf("got %d span(s), want %d", got, want)
176+
}
177+
if got, want := spans[0].Name, "POST /v1/logs"; got != want {
178+
t.Fatalf("span name = %q, want %q", got, want)
179+
}
180+
}

v1/test/e2e/distributedtracing/distributedtracing_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestServerSpan(t *testing.T) {
7878
if !spans[0].SpanContext.IsValid() {
7979
t.Fatalf("invalid span created: %#v", spans[0].SpanContext)
8080
}
81-
if got, expected := spans[0].Name, "v0/data"; got != expected {
81+
if got, expected := spans[0].Name, "POST v0/data"; got != expected {
8282
t.Fatalf("Expected span name to be %q but got %q", expected, got)
8383
}
8484
if got, expected := spans[0].SpanKind.String(), "server"; got != expected {
@@ -126,7 +126,7 @@ func TestServerSpan(t *testing.T) {
126126
if !spans[0].SpanContext.IsValid() {
127127
t.Fatalf("invalid span created: %#v", spans[0].SpanContext)
128128
}
129-
if got, expected := spans[0].Name, "v1/data"; got != expected {
129+
if got, expected := spans[0].Name, "GET v1/data"; got != expected {
130130
t.Fatalf("Expected span name to be %q but got %q", expected, got)
131131
}
132132
if got, expected := spans[0].SpanKind.String(), "server"; got != expected {
@@ -644,7 +644,7 @@ allow if {
644644
if !spans[0].SpanContext.IsValid() {
645645
t.Fatalf("invalid span created: %#v", spans[0].SpanContext)
646646
}
647-
if got, expected := spans[0].Name, server.PromHandlerAPIAuthz; got != expected {
647+
if got, expected := spans[0].Name, "POST "+server.PromHandlerAPIAuthz; got != expected {
648648
t.Fatalf("Expected span name to be %q but got %q", expected, got)
649649
}
650650
if got, expected := spans[0].SpanKind.String(), "server"; got != expected {
@@ -830,13 +830,13 @@ func TestControlPlaneSpans(t *testing.T) {
830830
t.Fatalf("invalid span created: %#v", span.SpanContext)
831831
}
832832
}
833-
if got, expected := spans[0].Name, "v1/data"; got != expected {
833+
if got, expected := spans[0].Name, "POST v1/data"; got != expected {
834834
t.Fatalf("Expected span name to be %q but got %q", expected, got)
835835
}
836836
if got, expected := spans[0].SpanKind.String(), "server"; got != expected {
837837
t.Fatalf("Expected span kind to be %q but got %q", expected, got)
838838
}
839-
if got, expected := spans[1].Name, "HTTP POST"; got != expected {
839+
if got, expected := spans[1].Name, "POST /logs"; got != expected {
840840
t.Fatalf("Expected span name to be %q but got %q", expected, got)
841841
}
842842
if got, expected := spans[1].SpanKind.String(), "client"; got != expected {

0 commit comments

Comments
 (0)