Skip to content

Commit 0761277

Browse files
ChrisJBurnsclaudegithub-actions[bot]jhrozek
authored
Add W3C Trace Context propagation via MCP _meta (#3682)
* Add W3C Trace Context propagation via MCP _meta Add MetaCarrier (TextMapCarrier for MCP _meta fields) and InjectMetaTraceContext for injecting traceparent/tracestate into outgoing MCP requests, per the MCP OTEL specification. This enables distributed tracing across vMCP → backend boundaries using the standard W3C Trace Context format propagated through MCP params._meta. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review comments for trace context propagation - Add compile-time assertion that MetaCarrier implements propagation.TextMapCarrier - Refactor TestMetaCarrier_GetSetKeys to use table-driven test pattern for Get operations Co-authored-by: Jakub Hrozek <jhrozek@users.noreply.github.com> * Fix lint: Add t.Parallel() to table-driven test subtests - Added t.Parallel() call in subtests to satisfy paralleltest/tparallel linters - Maintains test isolation while allowing parallel execution Co-authored-by: Chris Burns <ChrisJBurns@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jakub Hrozek <jhrozek@users.noreply.github.com> Co-authored-by: Chris Burns <ChrisJBurns@users.noreply.github.com>
1 parent a5e287d commit 0761277

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

pkg/telemetry/propagation.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package telemetry
5+
6+
import (
7+
"context"
8+
9+
"go.opentelemetry.io/otel"
10+
"go.opentelemetry.io/otel/propagation"
11+
)
12+
13+
// Compile-time assertion that MetaCarrier implements propagation.TextMapCarrier
14+
var _ propagation.TextMapCarrier = (*MetaCarrier)(nil)
15+
16+
// MetaCarrier implements propagation.TextMapCarrier for MCP _meta fields.
17+
// This enables W3C Trace Context propagation through MCP request params._meta,
18+
// as recommended by the MCP OpenTelemetry specification.
19+
//
20+
// The carrier wraps a map[string]interface{} (the _meta field from MCP params)
21+
// and allows the OpenTelemetry propagator to inject/extract traceparent and
22+
// tracestate headers into/from the map.
23+
type MetaCarrier struct {
24+
meta map[string]interface{}
25+
}
26+
27+
// NewMetaCarrier creates a new MetaCarrier wrapping the given meta map.
28+
// If meta is nil, a new empty map is created.
29+
func NewMetaCarrier(meta map[string]interface{}) *MetaCarrier {
30+
if meta == nil {
31+
meta = make(map[string]interface{})
32+
}
33+
return &MetaCarrier{meta: meta}
34+
}
35+
36+
// Get returns the value associated with the passed key.
37+
func (c *MetaCarrier) Get(key string) string {
38+
if v, ok := c.meta[key]; ok {
39+
if s, ok := v.(string); ok {
40+
return s
41+
}
42+
}
43+
return ""
44+
}
45+
46+
// Set stores the key-value pair.
47+
func (c *MetaCarrier) Set(key string, value string) {
48+
c.meta[key] = value
49+
}
50+
51+
// Keys lists the keys stored in this carrier.
52+
func (c *MetaCarrier) Keys() []string {
53+
keys := make([]string, 0, len(c.meta))
54+
for k := range c.meta {
55+
keys = append(keys, k)
56+
}
57+
return keys
58+
}
59+
60+
// Meta returns the underlying meta map. Use this after injection to retrieve
61+
// the enriched map containing trace context fields.
62+
func (c *MetaCarrier) Meta() map[string]interface{} {
63+
return c.meta
64+
}
65+
66+
// InjectMetaTraceContext injects the current trace context from ctx directly into
67+
// the given meta map using W3C Trace Context format (traceparent, tracestate).
68+
//
69+
// This function operates directly on the meta map contents. Use this when you
70+
// already have the _meta map and want to inject trace context fields into it.
71+
func InjectMetaTraceContext(ctx context.Context, meta map[string]interface{}) {
72+
if meta == nil {
73+
return
74+
}
75+
carrier := NewMetaCarrier(meta)
76+
otel.GetTextMapPropagator().Inject(ctx, carrier)
77+
}

pkg/telemetry/propagation_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package telemetry
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"go.opentelemetry.io/otel"
11+
"go.opentelemetry.io/otel/propagation"
12+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
13+
)
14+
15+
func TestMetaCarrier_GetSetKeys(t *testing.T) {
16+
t.Parallel()
17+
18+
meta := map[string]interface{}{
19+
"existing": "value",
20+
"number": 42,
21+
}
22+
carrier := NewMetaCarrier(meta)
23+
24+
// Table-driven test for Get operations
25+
getTests := []struct {
26+
name string
27+
key string
28+
want string
29+
}{
30+
{
31+
name: "existing string value",
32+
key: "existing",
33+
want: "value",
34+
},
35+
{
36+
name: "non-string value returns empty",
37+
key: "number",
38+
want: "",
39+
},
40+
{
41+
name: "non-existent key returns empty",
42+
key: "missing",
43+
want: "",
44+
},
45+
}
46+
47+
for _, tt := range getTests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
t.Parallel()
50+
if got := carrier.Get(tt.key); got != tt.want {
51+
t.Errorf("Get(%q) = %q, want %q", tt.key, got, tt.want)
52+
}
53+
})
54+
}
55+
56+
// Test Set
57+
carrier.Set("traceparent", "00-abc123-def456-01")
58+
if got := carrier.Get("traceparent"); got != "00-abc123-def456-01" {
59+
t.Errorf("Get(traceparent) after Set = %q, want %q", got, "00-abc123-def456-01")
60+
}
61+
62+
// Verify set also updates underlying map
63+
if v, ok := meta["traceparent"]; !ok || v != "00-abc123-def456-01" {
64+
t.Errorf("underlying map not updated: got %v", v)
65+
}
66+
67+
// Test Keys
68+
keys := carrier.Keys()
69+
if len(keys) != 3 { // existing, number, traceparent
70+
t.Errorf("Keys() returned %d keys, want 3", len(keys))
71+
}
72+
keyMap := make(map[string]bool)
73+
for _, k := range keys {
74+
keyMap[k] = true
75+
}
76+
for _, expected := range []string{"existing", "number", "traceparent"} {
77+
if !keyMap[expected] {
78+
t.Errorf("Keys() missing key %q", expected)
79+
}
80+
}
81+
}
82+
83+
func TestNewMetaCarrier_NilMeta(t *testing.T) {
84+
t.Parallel()
85+
86+
carrier := NewMetaCarrier(nil)
87+
if carrier.meta == nil {
88+
t.Error("NewMetaCarrier(nil) should create a non-nil map")
89+
}
90+
91+
carrier.Set("key", "value")
92+
if got := carrier.Get("key"); got != "value" {
93+
t.Errorf("Get(key) = %q, want %q", got, "value")
94+
}
95+
}
96+
97+
func TestMetaCarrier_Meta(t *testing.T) {
98+
t.Parallel()
99+
100+
original := map[string]interface{}{"foo": "bar"}
101+
carrier := NewMetaCarrier(original)
102+
103+
returned := carrier.Meta()
104+
if returned["foo"] != "bar" {
105+
t.Error("Meta() should return the underlying map")
106+
}
107+
108+
// Verify it's the same map (not a copy)
109+
carrier.Set("new", "val")
110+
if returned["new"] != "val" {
111+
t.Error("Meta() should return the same map reference")
112+
}
113+
}
114+
115+
// Tests below mutate the global OTEL propagator, so they must NOT use t.Parallel().
116+
117+
func TestInjectMetaTraceContext(t *testing.T) { //nolint:paralleltest // Mutates global OTEL propagator
118+
oldPropagator := otel.GetTextMapPropagator()
119+
otel.SetTextMapPropagator(propagation.TraceContext{})
120+
defer otel.SetTextMapPropagator(oldPropagator)
121+
122+
tp := sdktrace.NewTracerProvider()
123+
defer func() { _ = tp.Shutdown(context.Background()) }()
124+
tracer := tp.Tracer("test")
125+
ctx, span := tracer.Start(context.Background(), "test-span")
126+
defer span.End()
127+
128+
// InjectMetaTraceContext injects directly into the meta map
129+
meta := map[string]interface{}{
130+
"progressToken": "tok-456",
131+
}
132+
InjectMetaTraceContext(ctx, meta)
133+
134+
// traceparent should be added directly as a key in the meta map
135+
traceparent, ok := meta["traceparent"]
136+
if !ok {
137+
t.Fatal("traceparent not found in meta after InjectMetaTraceContext")
138+
}
139+
if tp1, ok := traceparent.(string); !ok || tp1 == "" {
140+
t.Errorf("traceparent = %v, want non-empty string", traceparent)
141+
}
142+
143+
// Existing fields should be preserved
144+
if meta["progressToken"] != "tok-456" {
145+
t.Error("existing progressToken was overwritten by InjectMetaTraceContext")
146+
}
147+
}
148+
149+
func TestInjectMetaTraceContext_NilMeta(t *testing.T) {
150+
t.Parallel()
151+
152+
// Should not panic
153+
InjectMetaTraceContext(context.Background(), nil)
154+
}

0 commit comments

Comments
 (0)