Skip to content

Commit b0bea72

Browse files
fix(otel): inject trace_id/span_id into logr context (llm-d#534)
* fix(otel): inject trace_id/span_id into logr context Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * fix(otel): prevent duplicate trace fields on nested spans Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> --------- Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> Co-authored-by: Lior Aronovich <243445518+lioraron@users.noreply.github.com>
1 parent 38c918f commit b0bea72

2 files changed

Lines changed: 207 additions & 1 deletion

File tree

internal/util/otel/otel.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,31 @@ const (
4949
AttrRequestFailed = "batch.request.failed"
5050
)
5151

52+
// baseLoggerKey stores the logger captured before the first trace enrichment.
53+
// Nested StartSpan calls enrich from this base rather than from the
54+
// accumulated context logger, preventing duplicate trace_id/span_id fields.
55+
type baseLoggerKey struct{}
56+
5257
// StartSpan creates a new span using the batch-gateway tracer.
58+
// When the span carries a valid trace context, the logger in the returned
59+
// context is enriched with trace_id and span_id so that all downstream
60+
// log lines emitted via logr.FromContextOrDiscard(ctx) are automatically
61+
// correlated with the active trace.
5362
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
54-
return otel.Tracer(defaultServiceName).Start(ctx, name, opts...)
63+
ctx, span := otel.Tracer(defaultServiceName).Start(ctx, name, opts...)
64+
if sc := span.SpanContext(); sc.IsValid() {
65+
base, ok := ctx.Value(baseLoggerKey{}).(logr.Logger)
66+
if !ok {
67+
base = logr.FromContextOrDiscard(ctx)
68+
ctx = context.WithValue(ctx, baseLoggerKey{}, base)
69+
}
70+
logger := base.WithValues(
71+
"trace_id", sc.TraceID().String(),
72+
"span_id", sc.SpanID().String(),
73+
)
74+
ctx = logr.NewContext(ctx, logger)
75+
}
76+
return ctx, span
5577
}
5678

5779
// SetAttr sets attributes on the span in the given context.
@@ -70,6 +92,9 @@ func DetachedContext(ctx context.Context, name string) (context.Context, trace.S
7092
links = append(links, trace.Link{SpanContext: sc})
7193
}
7294
bgCtx := logr.NewContext(context.Background(), logr.FromContextOrDiscard(ctx))
95+
if base, ok := ctx.Value(baseLoggerKey{}).(logr.Logger); ok {
96+
bgCtx = context.WithValue(bgCtx, baseLoggerKey{}, base)
97+
}
7398
return StartSpan(bgCtx, name, trace.WithLinks(links...))
7499
}
75100

internal/util/otel/otel_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
Copyright 2026 The llm-d 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 otel
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/go-logr/logr"
24+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
25+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
26+
27+
"go.opentelemetry.io/otel"
28+
)
29+
30+
// capturingSink is a logr.LogSink that records WithValues calls for test assertions.
31+
type capturingSink struct {
32+
values map[string]any
33+
}
34+
35+
var _ logr.LogSink = (*capturingSink)(nil)
36+
37+
func newCapturingSink() *capturingSink {
38+
return &capturingSink{values: make(map[string]any)}
39+
}
40+
41+
func (s *capturingSink) Init(logr.RuntimeInfo) {}
42+
func (s *capturingSink) Enabled(int) bool { return true }
43+
func (s *capturingSink) Info(int, string, ...any) {}
44+
func (s *capturingSink) Error(error, string, ...any) {}
45+
func (s *capturingSink) WithName(string) logr.LogSink { return s }
46+
func (s *capturingSink) WithValues(keysAndValues ...any) logr.LogSink {
47+
next := &capturingSink{values: make(map[string]any, len(s.values)+len(keysAndValues)/2)}
48+
for k, v := range s.values {
49+
next.values[k] = v
50+
}
51+
for i := 0; i+1 < len(keysAndValues); i += 2 {
52+
if key, ok := keysAndValues[i].(string); ok {
53+
next.values[key] = keysAndValues[i+1]
54+
}
55+
}
56+
return next
57+
}
58+
59+
func TestStartSpan_InjectsTraceFields(t *testing.T) {
60+
exporter := tracetest.NewInMemoryExporter()
61+
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
62+
defer tp.Shutdown(context.Background()) //nolint:errcheck
63+
original := otel.GetTracerProvider()
64+
otel.SetTracerProvider(tp)
65+
defer otel.SetTracerProvider(original)
66+
67+
sink := newCapturingSink()
68+
logger := logr.New(sink)
69+
ctx := logr.NewContext(context.Background(), logger)
70+
71+
ctx, span := StartSpan(ctx, "test-span")
72+
defer span.End()
73+
74+
sc := span.SpanContext()
75+
if !sc.IsValid() {
76+
t.Fatal("span context should be valid with a real tracer provider")
77+
}
78+
79+
enriched := logr.FromContextOrDiscard(ctx).GetSink().(*capturingSink)
80+
81+
if got, ok := enriched.values["trace_id"]; !ok {
82+
t.Error("expected trace_id in logger values")
83+
} else if got != sc.TraceID().String() {
84+
t.Errorf("trace_id = %q, want %q", got, sc.TraceID().String())
85+
}
86+
87+
if got, ok := enriched.values["span_id"]; !ok {
88+
t.Error("expected span_id in logger values")
89+
} else if got != sc.SpanID().String() {
90+
t.Errorf("span_id = %q, want %q", got, sc.SpanID().String())
91+
}
92+
}
93+
94+
func TestStartSpan_NoOpProvider_NoTraceFields(t *testing.T) {
95+
sink := newCapturingSink()
96+
logger := logr.New(sink)
97+
ctx := logr.NewContext(context.Background(), logger)
98+
99+
ctx, span := StartSpan(ctx, "noop-span")
100+
defer span.End()
101+
102+
result := logr.FromContextOrDiscard(ctx).GetSink().(*capturingSink)
103+
104+
if _, ok := result.values["trace_id"]; ok {
105+
t.Error("trace_id should not be present with no-op tracer provider")
106+
}
107+
if _, ok := result.values["span_id"]; ok {
108+
t.Error("span_id should not be present with no-op tracer provider")
109+
}
110+
}
111+
112+
func TestStartSpan_NestedSpans_NoDuplicateKeys(t *testing.T) {
113+
exporter := tracetest.NewInMemoryExporter()
114+
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
115+
defer tp.Shutdown(context.Background()) //nolint:errcheck
116+
original := otel.GetTracerProvider()
117+
otel.SetTracerProvider(tp)
118+
defer otel.SetTracerProvider(original)
119+
120+
sink := newCapturingSink()
121+
logger := logr.New(sink).WithValues("jobId", "job-123")
122+
ctx := logr.NewContext(context.Background(), logger)
123+
124+
ctx, outerSpan := StartSpan(ctx, "outer-span")
125+
defer outerSpan.End()
126+
127+
ctx, innerSpan := StartSpan(ctx, "inner-span")
128+
defer innerSpan.End()
129+
130+
innerSC := innerSpan.SpanContext()
131+
enriched := logr.FromContextOrDiscard(ctx).GetSink().(*capturingSink)
132+
133+
if got := enriched.values["span_id"]; got != innerSC.SpanID().String() {
134+
t.Errorf("span_id = %q, want inner span's %q", got, innerSC.SpanID().String())
135+
}
136+
if got := enriched.values["trace_id"]; got != innerSC.TraceID().String() {
137+
t.Errorf("trace_id = %q, want %q", got, innerSC.TraceID().String())
138+
}
139+
if got, ok := enriched.values["jobId"]; !ok || got != "job-123" {
140+
t.Errorf("pre-existing jobId should be preserved, got %v", got)
141+
}
142+
}
143+
144+
func TestDetachedContext_InjectsNewTraceFields(t *testing.T) {
145+
exporter := tracetest.NewInMemoryExporter()
146+
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
147+
defer tp.Shutdown(context.Background()) //nolint:errcheck
148+
original := otel.GetTracerProvider()
149+
otel.SetTracerProvider(tp)
150+
defer otel.SetTracerProvider(original)
151+
152+
sink := newCapturingSink()
153+
logger := logr.New(sink)
154+
parentCtx := logr.NewContext(context.Background(), logger)
155+
156+
parentCtx, parentSpan := StartSpan(parentCtx, "parent-span")
157+
defer parentSpan.End()
158+
parentSC := parentSpan.SpanContext()
159+
160+
detachedCtx, detachedSpan := DetachedContext(parentCtx, "detached-span")
161+
defer detachedSpan.End()
162+
detachedSC := detachedSpan.SpanContext()
163+
164+
if parentSC.TraceID() == detachedSC.TraceID() {
165+
t.Error("detached span should have a different trace_id than the parent")
166+
}
167+
168+
enriched := logr.FromContextOrDiscard(detachedCtx).GetSink().(*capturingSink)
169+
170+
if got, ok := enriched.values["trace_id"]; !ok {
171+
t.Error("expected trace_id in detached context logger")
172+
} else if got != detachedSC.TraceID().String() {
173+
t.Errorf("detached trace_id = %q, want %q (not parent's %q)", got, detachedSC.TraceID().String(), parentSC.TraceID().String())
174+
}
175+
176+
if got, ok := enriched.values["span_id"]; !ok {
177+
t.Error("expected span_id in detached context logger")
178+
} else if got != detachedSC.SpanID().String() {
179+
t.Errorf("detached span_id = %q, want %q", got, detachedSC.SpanID().String())
180+
}
181+
}

0 commit comments

Comments
 (0)