diff --git a/pkg/apiserver/endpoints/filters/upstream_trace_link.go b/pkg/apiserver/endpoints/filters/upstream_trace_link.go new file mode 100644 index 00000000000..0e16022b929 --- /dev/null +++ b/pkg/apiserver/endpoints/filters/upstream_trace_link.go @@ -0,0 +1,28 @@ +package filters + +import ( + "net/http" + + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// WithExtractUpstreamTraceLink extracts the Grafana-Upstream-Traceparent +// custom header (injected by the ST proxy) and adds a span link to the +// current span. This preserves trace relationships across the vanilla K8s +// API server, which drops incoming W3C trace context. +func WithExtractUpstreamTraceLink(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + upstreamTP := req.Header.Get("Grafana-Upstream-Traceparent") + if upstreamTP != "" { + prop := propagation.TraceContext{} + carrier := propagation.MapCarrier{"traceparent": upstreamTP} + extractedCtx := prop.Extract(req.Context(), carrier) + if sc := trace.SpanContextFromContext(extractedCtx); sc.IsValid() && sc.IsRemote() { + currentSpan := trace.SpanFromContext(req.Context()) + currentSpan.AddLink(trace.Link{SpanContext: sc}) + } + } + handler.ServeHTTP(w, req) + }) +} diff --git a/pkg/apiserver/endpoints/filters/upstream_trace_link_test.go b/pkg/apiserver/endpoints/filters/upstream_trace_link_test.go new file mode 100644 index 00000000000..0c9fa3b4681 --- /dev/null +++ b/pkg/apiserver/endpoints/filters/upstream_trace_link_test.go @@ -0,0 +1,94 @@ +package filters + +import ( + "net/http" + "net/http/httptest" + "testing" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestWithExtractUpstreamTraceLink(t *testing.T) { + tests := []struct { + name string + headerValue string + expectLink bool + expectCalled bool + }{ + { + name: "valid upstream traceparent adds span link", + headerValue: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + expectLink: true, + expectCalled: true, + }, + { + name: "missing header is a no-op", + headerValue: "", + expectLink: false, + expectCalled: true, + }, + { + name: "invalid traceparent is a no-op", + headerValue: "not-a-valid-traceparent", + expectLink: false, + expectCalled: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + tracer := tp.Tracer("test") + + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + handler := WithExtractUpstreamTraceLink(inner) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + if tc.headerValue != "" { + req.Header.Set("Grafana-Upstream-Traceparent", tc.headerValue) + } + + // Start a span to simulate WithTracing having already run. + ctx, span := tracer.Start(req.Context(), "test-span") + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + span.End() + + if called != tc.expectCalled { + t.Errorf("expected inner handler called=%v, got %v", tc.expectCalled, called) + } + + spans := recorder.Ended() + if len(spans) != 1 { + t.Fatalf("expected 1 span, got %d", len(spans)) + } + + links := spans[0].Links() + if tc.expectLink { + if len(links) != 1 { + t.Fatalf("expected 1 span link, got %d", len(links)) + } + if got := links[0].SpanContext.TraceID().String(); got != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Errorf("unexpected link trace ID: %s", got) + } + if got := links[0].SpanContext.SpanID().String(); got != "00f067aa0ba902b7" { + t.Errorf("unexpected link span ID: %s", got) + } + } else { + if len(links) != 0 { + t.Errorf("expected no span links, got %d", len(links)) + } + } + }) + } +} diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 0aa225a0641..eb8a3b3891d 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -10,6 +10,7 @@ require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 k8s.io/apimachinery v0.35.1 k8s.io/apiserver v0.35.1 @@ -84,7 +85,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect