diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index fb07058c9..661edec35 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/p2p" "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/pkg/telemetry" "github.com/evstack/ev-node/types" pb "github.com/evstack/ev-node/types/pb/evnode/v1" rpc "github.com/evstack/ev-node/types/pb/evnode/v1/v1connect" @@ -413,8 +414,13 @@ func NewServiceHandler( // Register custom HTTP endpoints RegisterCustomHTTPEndpoints(mux, store, peerManager, config, bestKnown, logger) + var handler http.Handler = mux + if config.Instrumentation.IsTracingEnabled() { + handler = telemetry.ExtractTraceContext(mux) + } + // Use h2c to support HTTP/2 without TLS - return h2c.NewHandler(mux, &http2.Server{ + return h2c.NewHandler(handler, &http2.Server{ IdleTimeout: 120 * time.Second, MaxReadFrameSize: 1 << 24, MaxConcurrentStreams: 100, diff --git a/pkg/telemetry/http_extract.go b/pkg/telemetry/http_extract.go new file mode 100644 index 000000000..0cbe77077 --- /dev/null +++ b/pkg/telemetry/http_extract.go @@ -0,0 +1,20 @@ +package telemetry + +import ( + "net/http" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +// ExtractTraceContext returns HTTP middleware that extracts W3C Trace Context +// headers (traceparent, tracestate) from incoming requests and adds them to +// the request context. This enables spans created downstream to be children +// of the caller's trace. +func ExtractTraceContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + prop := otel.GetTextMapPropagator() + ctx := prop.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/pkg/telemetry/http_extract_test.go b/pkg/telemetry/http_extract_test.go new file mode 100644 index 000000000..41f035ddc --- /dev/null +++ b/pkg/telemetry/http_extract_test.go @@ -0,0 +1,86 @@ +package telemetry + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +func TestExtractTraceContext_WithParentTrace(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + + tracer := tp.Tracer("test") + + // create a parent span to get a valid trace context + parentCtx, parentSpan := tracer.Start(context.Background(), "parent") + parentSpanCtx := parentSpan.SpanContext() + parentSpan.End() + + // inject the parent context into headers + headers := make(http.Header) + otel.GetTextMapPropagator().Inject(parentCtx, propagation.HeaderCarrier(headers)) + + var extractedSpanCtx trace.SpanContext + handler := ExtractTraceContext(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // create a child span using the extracted context + _, childSpan := tracer.Start(r.Context(), "child") + extractedSpanCtx = childSpan.SpanContext() + childSpan.End() + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header = headers + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, extractedSpanCtx.IsValid(), "child span should be valid") + require.Equal(t, parentSpanCtx.TraceID(), extractedSpanCtx.TraceID(), "child should have same trace ID as parent") +} + +func TestExtractTraceContext_WithoutParentTrace(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + + tracer := tp.Tracer("test") + + var extractedSpanCtx trace.SpanContext + handler := ExtractTraceContext(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // create a span - should be a root span since no parent headers + _, span := tracer.Start(r.Context(), "root") + extractedSpanCtx = span.SpanContext() + span.End() + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + // no trace headers + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, extractedSpanCtx.IsValid(), "span should be valid") + + // verify it's a root span by checking no parent exists in the recorded spans + spans := sr.Ended() + require.Len(t, spans, 1) + require.False(t, spans[0].Parent().IsValid(), "span should be a root span with no parent") +}