Skip to content

Commit 5216c6c

Browse files
authored
feat(tracing): HTTP response status on spans, sanitize error recording, bump semconv to v1.27.0 (#4879)
Three OTel best-practice gaps identified in the Go Fan module review: `WrapHTTPHandler` never recorded the HTTP response status code, `SetStatus` and `RecordError` were leaking raw error strings to trace backends, and all files were pinned to semconv `v1.26.0` despite `v1.27.0` being available. ## Changes - **`tracing/http.go` — capture HTTP response status on spans** - Added `statusResponseWriter` wrapper intercepting `WriteHeader` and `Write` (handles implicit 200 when only `Write` is called) - Added `Unwrap() http.ResponseWriter` to preserve optional interfaces (`http.Flusher`, `http.Hijacker`, etc.) so streaming/SSE handlers are not broken - Sets `http.response.status_code` attribute and `codes.Error` span status for 5xx via `defer`, ensuring attributes are recorded even if the handler panics - **`server/unified.go` — sanitize error recording** - `execSpan.SetStatus(codes.Error, err.Error())` → `execSpan.SetStatus(codes.Error, "tool execution failed")` - `execSpan.RecordError(err)` → `execSpan.RecordError(fmt.Errorf("tool execution failed"))` — prevents raw upstream error text from appearing as `exception.message` in trace backends; the full error is still returned to the caller via `mcp.NewErrorCallToolResult(err)` - **semconv `v1.26.0` → `v1.27.0`** across all four affected files (`tracing/http.go`, `tracing/provider.go`, `server/unified.go`, `proxy/handler.go`); no go.mod/go.sum changes required as semconv is a sub-package of the already-imported `go.opentelemetry.io/otel v1.43.0` - **Tests** — added three new test cases to `internal/tracing/provider_test.go` covering the HTTP status recording behaviour: - Explicit `WriteHeader(404)` is reflected in the span attribute; 4xx does not set `codes.Error` - Implicit 200 via `Write` without `WriteHeader` is correctly recorded - 5xx sets span status to `codes.Error` with a non-empty description
2 parents 63377f4 + 9e66b78 commit 5216c6c

5 files changed

Lines changed: 171 additions & 7 deletions

File tree

internal/proxy/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
"go.opentelemetry.io/otel/attribute"
1414
"go.opentelemetry.io/otel/codes"
15-
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
15+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
1616
oteltrace "go.opentelemetry.io/otel/trace"
1717

1818
"github.com/github/gh-aw-mcpg/internal/difc"

internal/server/unified.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
"go.opentelemetry.io/otel/attribute"
1313
"go.opentelemetry.io/otel/codes"
14-
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
14+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
1515
oteltrace "go.opentelemetry.io/otel/trace"
1616

1717
"github.com/github/gh-aw-mcpg/internal/config"
@@ -552,8 +552,10 @@ func (us *UnifiedServer) callBackendTool(ctx context.Context, serverID, toolName
552552
// Transport errors (connection failure, JSON parse error, etc.) are not rate-limit
553553
// events and must not affect the consecutive rate-limit counter. Leave the circuit
554554
// breaker state unchanged so genuine rate-limit history is preserved.
555-
execSpan.RecordError(err)
556-
execSpan.SetStatus(codes.Error, err.Error())
555+
// Use a generic error message for trace recording to avoid leaking internal details
556+
// to trace backends; the full error is returned to the caller and logged separately.
557+
execSpan.RecordError(fmt.Errorf("tool execution failed"))
558+
execSpan.SetStatus(codes.Error, "tool execution failed")
557559
httpStatusCode = 500
558560
return mcp.NewErrorCallToolResult(err)
559561
}

internal/tracing/http.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,39 @@ import (
77

88
"go.opentelemetry.io/otel"
99
"go.opentelemetry.io/otel/attribute"
10+
"go.opentelemetry.io/otel/codes"
1011
"go.opentelemetry.io/otel/propagation"
11-
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
12+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
1213
oteltrace "go.opentelemetry.io/otel/trace"
1314
)
1415

16+
// statusResponseWriter wraps http.ResponseWriter to capture the HTTP response status code.
17+
// Unwrap allows http.ResponseController and other callers to access the underlying
18+
// writer's optional interfaces (e.g. http.Flusher, http.Hijacker) transparently.
19+
type statusResponseWriter struct {
20+
http.ResponseWriter
21+
statusCode int
22+
}
23+
24+
func (rw *statusResponseWriter) WriteHeader(code int) {
25+
rw.statusCode = code
26+
rw.ResponseWriter.WriteHeader(code)
27+
}
28+
29+
// Write captures an implicit 200 status when Write is called without a prior WriteHeader.
30+
func (rw *statusResponseWriter) Write(b []byte) (int, error) {
31+
if rw.statusCode == 0 {
32+
rw.statusCode = http.StatusOK
33+
}
34+
return rw.ResponseWriter.Write(b)
35+
}
36+
37+
// Unwrap returns the underlying http.ResponseWriter so that callers can type-assert
38+
// optional interfaces such as http.Flusher or http.Hijacker against the real writer.
39+
func (rw *statusResponseWriter) Unwrap() http.ResponseWriter {
40+
return rw.ResponseWriter
41+
}
42+
1543
// WrapHTTPHandler wraps an http.Handler with an OpenTelemetry server span.
1644
// A span named spanName is created for every request, with
1745
// http.request.method and url.path set automatically. Extra attrs are merged in.
@@ -48,6 +76,13 @@ func WrapHTTPHandler(next http.Handler, spanName string, extraAttrs ...attribute
4876

4977
logTracing.Printf("Span started: span=%s, traceID=%s", spanName, span.SpanContext().TraceID())
5078

51-
next.ServeHTTP(w, r.WithContext(ctx))
79+
srw := &statusResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
80+
defer func() {
81+
span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(srw.statusCode))
82+
if srw.statusCode >= 500 {
83+
span.SetStatus(codes.Error, http.StatusText(srw.statusCode))
84+
}
85+
}()
86+
next.ServeHTTP(srw, r.WithContext(ctx))
5287
})
5388
}

internal/tracing/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import (
3030
"go.opentelemetry.io/otel/propagation"
3131
"go.opentelemetry.io/otel/sdk/resource"
3232
sdktrace "go.opentelemetry.io/otel/sdk/trace"
33-
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
33+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
3434
"go.opentelemetry.io/otel/trace"
3535
"go.opentelemetry.io/otel/trace/noop"
3636

internal/tracing/provider_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import (
1111
"github.com/stretchr/testify/assert"
1212
"github.com/stretchr/testify/require"
1313
"go.opentelemetry.io/otel"
14+
"go.opentelemetry.io/otel/codes"
1415
"go.opentelemetry.io/otel/propagation"
1516
sdktrace "go.opentelemetry.io/otel/sdk/trace"
1617
"go.opentelemetry.io/otel/sdk/trace/tracetest"
18+
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
1719
"go.opentelemetry.io/otel/trace"
1820
"go.opentelemetry.io/otel/trace/noop"
1921

@@ -614,3 +616,128 @@ func TestParentContext_AllZerosTraceID(t *testing.T) {
614616
assert.Equal(t, ctx, parentCtx,
615617
"ParentContext with an all-zeros traceId must return the original context unchanged")
616618
}
619+
620+
// newInMemoryProvider creates a fresh in-process SDK tracer provider with an in-memory
621+
// exporter and registers it globally. The returned cleanup func restores the noop provider.
622+
func newInMemoryProvider(t *testing.T) (*sdktrace.TracerProvider, *tracetest.InMemoryExporter, func()) {
623+
t.Helper()
624+
exporter := tracetest.NewInMemoryExporter()
625+
sp := sdktrace.NewSimpleSpanProcessor(exporter)
626+
tp := sdktrace.NewTracerProvider(
627+
sdktrace.WithSpanProcessor(sp),
628+
sdktrace.WithSampler(sdktrace.AlwaysSample()),
629+
)
630+
otel.SetTracerProvider(tp)
631+
return tp, exporter, func() { otel.SetTracerProvider(noop.NewTracerProvider()) }
632+
}
633+
634+
// TestWrapHTTPHandler_RecordsExplicitStatusCode verifies that an explicit WriteHeader call
635+
// is reflected in the http.response.status_code span attribute.
636+
func TestWrapHTTPHandler_RecordsExplicitStatusCode(t *testing.T) {
637+
ctx := context.Background()
638+
639+
provider, err := tracing.InitProvider(ctx, nil)
640+
require.NoError(t, err)
641+
defer provider.Shutdown(ctx)
642+
643+
_, exporter, cleanup := newInMemoryProvider(t)
644+
defer cleanup()
645+
646+
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
647+
rr := httptest.NewRecorder()
648+
649+
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
650+
w.WriteHeader(http.StatusNotFound) // explicit 404
651+
})
652+
653+
tracing.WrapHTTPHandler(inner, "test.status").ServeHTTP(rr, req)
654+
655+
spans := exporter.GetSpans()
656+
require.Len(t, spans, 1, "expected exactly one span")
657+
span := spans[0]
658+
659+
// Span must carry http.response.status_code = 404
660+
var found bool
661+
for _, attr := range span.Attributes {
662+
if attr.Key == semconv.HTTPResponseStatusCodeKey {
663+
assert.Equal(t, int64(http.StatusNotFound), attr.Value.AsInt64(), "status code attribute must be 404")
664+
found = true
665+
}
666+
}
667+
assert.True(t, found, "http.response.status_code attribute must be present on the span")
668+
669+
// 4xx must NOT set span status to Error
670+
assert.Equal(t, codes.Unset, span.Status.Code, "4xx responses must not set span status to Error")
671+
}
672+
673+
// TestWrapHTTPHandler_RecordsImplicit200 verifies that a handler that calls Write
674+
// without an explicit WriteHeader is recorded as status 200.
675+
func TestWrapHTTPHandler_RecordsImplicit200(t *testing.T) {
676+
ctx := context.Background()
677+
678+
provider, err := tracing.InitProvider(ctx, nil)
679+
require.NoError(t, err)
680+
defer provider.Shutdown(ctx)
681+
682+
_, exporter, cleanup := newInMemoryProvider(t)
683+
defer cleanup()
684+
685+
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
686+
rr := httptest.NewRecorder()
687+
688+
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
689+
_, _ = w.Write([]byte("ok")) // implicit 200 — no WriteHeader call
690+
})
691+
692+
tracing.WrapHTTPHandler(inner, "test.implicit200").ServeHTTP(rr, req)
693+
694+
spans := exporter.GetSpans()
695+
require.Len(t, spans, 1, "expected exactly one span")
696+
697+
var found bool
698+
for _, attr := range spans[0].Attributes {
699+
if attr.Key == semconv.HTTPResponseStatusCodeKey {
700+
assert.Equal(t, int64(http.StatusOK), attr.Value.AsInt64(), "implicit Write must record status 200")
701+
found = true
702+
}
703+
}
704+
assert.True(t, found, "http.response.status_code attribute must be present on the span")
705+
}
706+
707+
// TestWrapHTTPHandler_5xxSetsSpanStatusError verifies that a 5xx response sets span
708+
// status to codes.Error and leaves a non-empty description.
709+
func TestWrapHTTPHandler_5xxSetsSpanStatusError(t *testing.T) {
710+
ctx := context.Background()
711+
712+
provider, err := tracing.InitProvider(ctx, nil)
713+
require.NoError(t, err)
714+
defer provider.Shutdown(ctx)
715+
716+
_, exporter, cleanup := newInMemoryProvider(t)
717+
defer cleanup()
718+
719+
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
720+
rr := httptest.NewRecorder()
721+
722+
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
723+
w.WriteHeader(http.StatusInternalServerError) // 500
724+
})
725+
726+
tracing.WrapHTTPHandler(inner, "test.5xx").ServeHTTP(rr, req)
727+
728+
spans := exporter.GetSpans()
729+
require.Len(t, spans, 1, "expected exactly one span")
730+
span := spans[0]
731+
732+
assert.Equal(t, codes.Error, span.Status.Code, "5xx must set span status to Error")
733+
assert.NotEmpty(t, span.Status.Description, "5xx must provide a non-empty status description")
734+
735+
var found bool
736+
for _, attr := range span.Attributes {
737+
if attr.Key == semconv.HTTPResponseStatusCodeKey {
738+
assert.Equal(t, int64(http.StatusInternalServerError), attr.Value.AsInt64())
739+
found = true
740+
}
741+
}
742+
assert.True(t, found, "http.response.status_code attribute must be present on the span")
743+
}

0 commit comments

Comments
 (0)