Skip to content

Commit 090b6ae

Browse files
Added OpenTelemetry W3C Trace Context (#16168)
* Added OpenTelemetry W3C Trace Context support while maintaining full backward compatibility with Zipkin B3 format. Signed-off-by: Somil Jain <[email protected]> * Fixed linting issue, removed fallback Signed-off-by: Somil Jain <[email protected]> --------- Signed-off-by: Somil Jain <[email protected]>
1 parent fd49d7a commit 090b6ae

File tree

10 files changed

+362
-9
lines changed

10 files changed

+362
-9
lines changed

config/core/configmaps/observability.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ metadata:
2222
app.kubernetes.io/component: observability
2323
app.kubernetes.io/version: devel
2424
annotations:
25-
knative.dev/example-checksum: "f183bbc6"
25+
knative.dev/example-checksum: "59abacb5"
2626
data:
2727
_example: |
2828
################################
@@ -80,7 +80,7 @@ data:
8080
# PodIP string // IP of the pod hosting the revision
8181
# }
8282
#
83-
logging.request-log-template: '{"httpRequest": {"requestMethod": "{{.Request.Method}}", "requestUrl": "{{js .Request.RequestURI}}", "requestSize": "{{.Request.ContentLength}}", "status": {{.Response.Code}}, "responseSize": "{{.Response.Size}}", "userAgent": "{{js .Request.UserAgent}}", "remoteIp": "{{js .Request.RemoteAddr}}", "serverIp": "{{.Revision.PodIP}}", "referer": "{{js .Request.Referer}}", "latency": "{{.Response.Latency}}s", "protocol": "{{.Request.Proto}}"}, "traceId": "{{index .Request.Header "X-B3-Traceid"}}"}'
83+
logging.request-log-template: '{"httpRequest": {"requestMethod": "{{.Request.Method}}", "requestUrl": "{{js .Request.RequestURI}}", "requestSize": "{{.Request.ContentLength}}", "status": {{.Response.Code}}, "responseSize": "{{.Response.Size}}", "userAgent": "{{js .Request.UserAgent}}", "remoteIp": "{{js .Request.RemoteAddr}}", "serverIp": "{{.Revision.PodIP}}", "referer": "{{js .Request.Referer}}", "latency": "{{.Response.Latency}}s", "protocol": "{{.Request.Proto}}"}, "traceId": "{{.TraceID}}"}'
8484
8585
# If true, the request logging will be enabled.
8686
logging.enable-request-log: "false"

pkg/http/request_log.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type RequestLogTemplateInput struct {
6666
Request *http.Request
6767
Response *RequestLogResponse
6868
Revision *RequestLogRevision
69+
TraceID string // Extracted from W3C Trace Context (traceparent) or B3 (X-B3-TraceId) headers
6970
}
7071

7172
// RequestLogTemplateInputGetter defines a function returning the input to pass to a request log writer.
@@ -79,6 +80,7 @@ func RequestLogTemplateInputGetterFromRevision(rev *RequestLogRevision) RequestL
7980
Request: req,
8081
Response: resp,
8182
Revision: rev,
83+
TraceID: ExtractTraceID(req.Header),
8284
}
8385
}
8486
}

pkg/http/request_log_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ func BenchmarkRequestLogHandlerNoTemplate(b *testing.B) {
258258

259259
func BenchmarkRequestLogHandlerDefaultTemplate(b *testing.B) {
260260
// Taken from config-observability.yaml
261-
tpl := `{"httpRequest": {"requestMethod": "{{.Request.Method}}", "requestUrl": "{{js .Request.RequestURI}}", "requestSize": "{{.Request.ContentLength}}", "status": {{.Response.Code}}, "responseSize": "{{.Response.Size}}", "userAgent": "{{js .Request.UserAgent}}", "remoteIp": "{{js .Request.RemoteAddr}}", "serverIp": "{{.Revision.PodIP}}", "referer": "{{js .Request.Referer}}", "latency": "{{.Response.Latency}}s", "protocol": "{{.Request.Proto}}"}, "traceId": "{{index .Request.Header "X-B3-Traceid"}}"}`
261+
tpl := `{"httpRequest": {"requestMethod": "{{.Request.Method}}", "requestUrl": "{{js .Request.RequestURI}}", "requestSize": "{{.Request.ContentLength}}", "status": {{.Response.Code}}, "responseSize": "{{.Response.Size}}", "userAgent": "{{js .Request.UserAgent}}", "remoteIp": "{{js .Request.RemoteAddr}}", "serverIp": "{{.Revision.PodIP}}", "referer": "{{js .Request.Referer}}", "latency": "{{.Response.Latency}}s", "protocol": "{{.Request.Proto}}"}, "traceId": "{{.TraceID}}"}`
262262
handler, err := NewRequestLogHandler(baseHandler, io.Discard, tpl, defaultInputGetter, false)
263263
if err != nil {
264264
b.Fatal("Failed to create handler:", err)

pkg/http/trace.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
Copyright 2025 The Knative Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package http
18+
19+
import (
20+
"net/http"
21+
"strings"
22+
)
23+
24+
// ExtractTraceID extracts the trace ID from the request headers.
25+
// It supports both W3C Trace Context (traceparent) and B3 (X-B3-TraceId) formats.
26+
func ExtractTraceID(h http.Header) string {
27+
//nolint:canonicalheader
28+
if traceparent := h.Get("traceparent"); traceparent != "" {
29+
parts := strings.SplitN(traceparent, "-", 3)
30+
if len(parts) >= 2 {
31+
return parts[1]
32+
}
33+
}
34+
35+
//nolint:canonicalheader
36+
if b3TraceID := h.Get("X-B3-TraceId"); b3TraceID != "" {
37+
return b3TraceID
38+
}
39+
40+
return ""
41+
}

pkg/http/trace_integration_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
Copyright 2025 The Knative Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package http
18+
19+
import (
20+
"bytes"
21+
"net/http"
22+
"net/http/httptest"
23+
"strings"
24+
"testing"
25+
)
26+
27+
// TestTraceIDInRequestLog verifies that trace IDs are properly extracted
28+
// and included in request logs for both W3C Trace Context and B3 formats.
29+
func TestTraceIDInRequestLog(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
headers map[string]string
33+
expectedTraceID string
34+
description string
35+
}{{
36+
name: "W3C Trace Context (OpenTelemetry)",
37+
headers: map[string]string{
38+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
39+
},
40+
expectedTraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
41+
description: "Should extract trace ID from W3C traceparent header",
42+
}, {
43+
name: "B3 Format (Legacy)",
44+
headers: map[string]string{
45+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
46+
"X-B3-SpanId": "00f067aa0ba902b7",
47+
},
48+
expectedTraceID: "80f198ee56343ba864fe8b2a57d3eff7",
49+
description: "Should extract trace ID from B3 X-B3-TraceId header",
50+
}, {
51+
name: "Both formats present (W3C preferred)",
52+
headers: map[string]string{
53+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
54+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
55+
},
56+
expectedTraceID: "0af7651916cd43dd8448eb211c80319c",
57+
description: "Should prefer W3C Trace Context over B3 when both are present",
58+
}, {
59+
name: "No trace headers",
60+
headers: map[string]string{},
61+
expectedTraceID: "",
62+
description: "Should return empty string when no trace headers present",
63+
}}
64+
65+
for _, tt := range tests {
66+
t.Run(tt.name, func(t *testing.T) {
67+
var logOutput bytes.Buffer
68+
69+
// Use the default template format with TraceID field
70+
template := `{"traceId": "{{.TraceID}}", "status": {{.Response.Code}}}`
71+
72+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+
w.WriteHeader(http.StatusOK)
74+
})
75+
76+
rev := &RequestLogRevision{
77+
Name: "test-rev",
78+
Namespace: "test-ns",
79+
Service: "test-svc",
80+
}
81+
82+
logHandler, err := NewRequestLogHandler(
83+
handler,
84+
&logOutput,
85+
template,
86+
RequestLogTemplateInputGetterFromRevision(rev),
87+
false,
88+
)
89+
if err != nil {
90+
t.Fatal("Failed to create log handler:", err)
91+
}
92+
93+
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
94+
for k, v := range tt.headers {
95+
req.Header.Set(k, v)
96+
}
97+
98+
resp := httptest.NewRecorder()
99+
logHandler.ServeHTTP(resp, req)
100+
101+
logLine := logOutput.String()
102+
if tt.expectedTraceID != "" {
103+
expectedLog := `"traceId": "` + tt.expectedTraceID + `"`
104+
if !strings.Contains(logLine, expectedLog) {
105+
t.Errorf("%s\nExpected log to contain: %s\nGot: %s", tt.description, expectedLog, logLine)
106+
}
107+
} else {
108+
// When no trace ID, should have empty string
109+
expectedLog := `"traceId": ""`
110+
if !strings.Contains(logLine, expectedLog) {
111+
t.Errorf("%s\nExpected log to contain empty traceId\nGot: %s", tt.description, logLine)
112+
}
113+
}
114+
})
115+
}
116+
}
117+
118+
// TestRequestLogTemplateWithTraceID tests the full request log template
119+
// using the same format as production (config-observability.yaml).
120+
func TestRequestLogTemplateWithTraceID(t *testing.T) {
121+
var logOutput bytes.Buffer
122+
123+
// Use the production template format
124+
template := `{"httpRequest": {"requestMethod": "{{.Request.Method}}", "status": {{.Response.Code}}}, "traceId": "{{.TraceID}}"}`
125+
126+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127+
w.WriteHeader(http.StatusOK)
128+
})
129+
130+
rev := &RequestLogRevision{
131+
Name: "my-service-abc123",
132+
Namespace: "default",
133+
Service: "my-service",
134+
}
135+
136+
logHandler, err := NewRequestLogHandler(
137+
handler,
138+
&logOutput,
139+
template,
140+
RequestLogTemplateInputGetterFromRevision(rev),
141+
false,
142+
)
143+
if err != nil {
144+
t.Fatal("Failed to create log handler:", err)
145+
}
146+
147+
// Test with W3C Trace Context
148+
req := httptest.NewRequest(http.MethodPost, "http://example.com/api", nil)
149+
//nolint:canonicalheader
150+
req.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
151+
152+
resp := httptest.NewRecorder()
153+
logHandler.ServeHTTP(resp, req)
154+
155+
logLine := logOutput.String()
156+
157+
// Verify all expected fields are present
158+
expectedFields := []string{
159+
`"requestMethod": "POST"`,
160+
`"status": 200`,
161+
`"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"`,
162+
}
163+
164+
for _, expected := range expectedFields {
165+
if !strings.Contains(logLine, expected) {
166+
t.Errorf("Expected log to contain: %s\nGot: %s", expected, logLine)
167+
}
168+
}
169+
}

pkg/http/trace_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
Copyright 2025 The Knative Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package http
18+
19+
import (
20+
"net/http"
21+
"testing"
22+
)
23+
24+
func TestExtractTraceID(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
headers map[string]string
28+
want string
29+
}{{
30+
name: "W3C Trace Context traceparent",
31+
headers: map[string]string{
32+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
33+
},
34+
want: "4bf92f3577b34da6a3ce929d0e0e4736",
35+
}, {
36+
name: "W3C Trace Context with tracestate",
37+
headers: map[string]string{
38+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
39+
"tracestate": "congo=t61rcWkgMzE",
40+
},
41+
want: "0af7651916cd43dd8448eb211c80319c",
42+
}, {
43+
name: "B3 TraceId (uppercase)",
44+
headers: map[string]string{
45+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
46+
},
47+
want: "80f198ee56343ba864fe8b2a57d3eff7",
48+
}, {
49+
name: "B3 Traceid (lowercase 'id')",
50+
headers: map[string]string{
51+
"X-B3-Traceid": "463ac35c9f6413ad48485a3953bb6124",
52+
},
53+
want: "463ac35c9f6413ad48485a3953bb6124",
54+
}, {
55+
name: "W3C Trace Context preferred over B3",
56+
headers: map[string]string{
57+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
58+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
59+
},
60+
want: "4bf92f3577b34da6a3ce929d0e0e4736", // W3C takes precedence
61+
}, {
62+
name: "B3 short format (8 bytes)",
63+
headers: map[string]string{
64+
"X-B3-TraceId": "463ac35c9f6413ad",
65+
},
66+
want: "463ac35c9f6413ad",
67+
}, {
68+
name: "No trace headers",
69+
headers: map[string]string{
70+
"Content-Type": "application/json",
71+
},
72+
want: "",
73+
}, {
74+
name: "Empty headers",
75+
headers: map[string]string{},
76+
want: "",
77+
}, {
78+
name: "Invalid traceparent format",
79+
headers: map[string]string{
80+
"traceparent": "invalid",
81+
},
82+
want: "",
83+
}, {
84+
name: "Traceparent with only version",
85+
headers: map[string]string{
86+
"traceparent": "00",
87+
},
88+
want: "",
89+
}}
90+
91+
for _, tt := range tests {
92+
t.Run(tt.name, func(t *testing.T) {
93+
header := make(http.Header)
94+
for k, v := range tt.headers {
95+
header.Set(k, v)
96+
}
97+
98+
got := ExtractTraceID(header)
99+
if got != tt.want {
100+
t.Errorf("ExtractTraceID() = %v, want %v", got, tt.want)
101+
}
102+
})
103+
}
104+
}
105+
106+
func BenchmarkExtractTraceID(b *testing.B) {
107+
benchmarks := []struct {
108+
name string
109+
headers map[string]string
110+
}{{
111+
name: "W3C Trace Context",
112+
headers: map[string]string{
113+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
114+
},
115+
}, {
116+
name: "B3 TraceId",
117+
headers: map[string]string{
118+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
119+
},
120+
}, {
121+
name: "Both formats (W3C preferred)",
122+
headers: map[string]string{
123+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
124+
"X-B3-TraceId": "80f198ee56343ba864fe8b2a57d3eff7",
125+
},
126+
}}
127+
128+
for _, bm := range benchmarks {
129+
b.Run(bm.name, func(b *testing.B) {
130+
header := make(http.Header)
131+
for k, v := range bm.headers {
132+
header.Set(k, v)
133+
}
134+
135+
b.ResetTimer()
136+
for range b.N {
137+
ExtractTraceID(header)
138+
}
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)