Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6755b18
fix: use HTTP method and path as span name instead of generic ServeHTTP
ankurs Mar 28, 2026
0b64e49
feat: record HTTP status code and error status on OTEL spans
ankurs Mar 28, 2026
91ad35b
refactor: upgrade semconv from v1.12.0 to v1.40.0
ankurs Mar 28, 2026
f705ea1
feat: use grpc-gateway route patterns for low-cardinality span names
ankurs Mar 28, 2026
03b739d
fix: capture first WriteHeader only, split server.address from port
ankurs Mar 29, 2026
96f996b
feat: add git commit, go version, and VCS info to OTEL resource
ankurs Mar 29, 2026
567810b
feat: add git commit, go version, and binary name to OTEL resource
ankurs Mar 29, 2026
ab78d2d
fix: handle 1xx responses and implicit Write in statusRecorder
ankurs Mar 29, 2026
c810545
fix: treat 101 Switching Protocols as final status in statusRecorder
ankurs Mar 29, 2026
d0cc95c
fix: update statusRecorder comment for 101, strip IPv6 brackets from …
ankurs Mar 29, 2026
e4c4757
fix: use runtime.HTTPPattern for route extraction, add span attribute…
ankurs Mar 29, 2026
be90f26
fix: guard empty server.address, find spans by name, add 5xx error test
ankurs Mar 29, 2026
3e48e72
fix: record 500 status on span when handler panics before writing hea…
ankurs Mar 29, 2026
9e698f8
fix: derive url.scheme from r.TLS instead of empty r.URL.Scheme
ankurs Mar 29, 2026
2edd9c7
merge: resolve conflicts with main, keep PR fixes
ankurs Mar 29, 2026
13df802
fix: check X-Forwarded-Proto for url.scheme behind TLS-terminating pr…
ankurs Mar 29, 2026
f5b473c
fix: always set span status Error on panic, assert url.scheme in test
ankurs Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ func httpSpanAttributes(r *http.Request) []attribute.KeyValue {
attrs := []attribute.KeyValue{
semconv.HTTPRequestMethodKey.String(r.Method),
semconv.URLPath(r.URL.Path),
semconv.ServerAddress(host),
}
if host != "" {
attrs = append(attrs, semconv.ServerAddress(host))
}
if port != "" {
if p, err := strconv.Atoi(port); err == nil {
Expand All @@ -235,9 +237,17 @@ func httpSpanAttributes(r *http.Request) []attribute.KeyValue {
if r.URL.RawQuery != "" {
attrs = append(attrs, semconv.URLQuery(r.URL.RawQuery))
}
if r.URL.Scheme != "" {
attrs = append(attrs, semconv.URLScheme(r.URL.Scheme))
scheme := r.URL.Scheme
if scheme == "" {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
attrs = append(attrs, semconv.URLScheme(scheme))
Comment on lines +246 to +250
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

httpSpanAttributes is documented as "omitting empty-valued attributes", but the new logic always appends url.scheme (defaulting to "http"). Either update the function comment to match the new behavior, or only set url.scheme when it’s explicitly known (URL scheme provided, TLS present, or a trusted forwarded-proto header).

Suggested change
} else {
scheme = "http"
}
}
attrs = append(attrs, semconv.URLScheme(scheme))
}
}
if scheme != "" {
attrs = append(attrs, semconv.URLScheme(scheme))
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the comment — url.scheme is now always set (derived from X-Forwarded-Proto / r.TLS / default http). The function comment should be updated. Will fix if we do another pass on this PR.

return attrs
}

Expand All @@ -256,7 +266,18 @@ func tracingWrapper(h http.Handler) http.Handler {
)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
w = rec
defer endSpan(serverSpan, rec)
defer func() {
if recovered := recover(); recovered != nil {
if !rec.wroteHeader {
rec.status = http.StatusInternalServerError
}
serverSpan.RecordError(fmt.Errorf("panic: %v", recovered))
Comment thread
ankurs marked this conversation as resolved.
serverSpan.SetStatus(codes.Error, "panic")
endSpan(serverSpan, rec)
panic(recovered)
}
endSpan(serverSpan, rec)
Comment on lines +269 to +279
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new panic recovery path updates the span (RecordError/SetStatus) and may force a 500 status, but there’s no test covering this behavior. Add a test that triggers a panic in the wrapped handler, asserts the request panics (recovered in the test), and verifies the exported span has http.response.status_code=500 and Status.Code==Error (and ideally an error event/recorded error).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — a panic recovery test would strengthen coverage. Adding OTel SDK as a test dep already enabled span assertions; a panic test is a good follow-up alongside the existing TestTracingWrapperSpanErrorStatus.

}()
}

_, han := interceptors.NRHttpTracer("", h.ServeHTTP)
Expand All @@ -268,12 +289,15 @@ func tracingWrapper(h http.Handler) http.Handler {

// spanRouteMiddleware is a grpc-gateway middleware that updates the OTEL span
// name and http.route attribute with the matched route pattern after routing.
// It uses runtime.HTTPPattern (the Pattern struct set by handleHandler) rather
// than runtime.HTTPPathPattern (the string set later inside AnnotateContext).
func spanRouteMiddleware(next runtime.HandlerFunc) runtime.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
if pattern, ok := runtime.HTTPPathPattern(r.Context()); ok {
if pattern, ok := runtime.HTTPPattern(r.Context()); ok {
route := pattern.String()
span := oteltrace.SpanFromContext(r.Context())
span.SetName(r.Method + " " + pattern)
span.SetAttributes(semconv.HTTPRoute(pattern))
span.SetName(r.Method + " " + route)
span.SetAttributes(semconv.HTTPRoute(route))
}
next(w, r, pathParams)
}
Expand Down
141 changes: 141 additions & 0 deletions core_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import (

"github.com/go-coldbrew/core/config"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/wrapperspb"
)
Expand Down Expand Up @@ -286,6 +290,143 @@ func TestSpanRouteMiddleware(t *testing.T) {
})
}

// setupTestTracer installs an in-memory span exporter and returns it along with
// a cleanup function that restores the previous tracer provider.
func setupTestTracer() (*tracetest.InMemoryExporter, func()) {
exporter := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
prev := otel.GetTracerProvider()
otel.SetTracerProvider(tp)
return exporter, func() {
otel.SetTracerProvider(prev)
_ = tp.Shutdown(context.Background())
}
Comment thread
ankurs marked this conversation as resolved.
}

// findSpanByName returns the first span with the given name, or nil if not found.
func findSpanByName(spans tracetest.SpanStubs, name string) *tracetest.SpanStub {
for i := range spans {
if spans[i].Name == name {
return &spans[i]
}
}
return nil
Comment thread
ankurs marked this conversation as resolved.
}

// spanAttrMap returns a map of attribute key to value for a span.
func spanAttrMap(span *tracetest.SpanStub) map[string]any {
m := make(map[string]any, len(span.Attributes))
for _, a := range span.Attributes {
m[string(a.Key)] = a.Value.AsInterface()
}
return m
}

func TestTracingWrapperSpanAttributes(t *testing.T) {
exporter, cleanup := setupTestTracer()
defer cleanup()

inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := tracingWrapper(inner)

req := httptest.NewRequest("GET", "/api/v1/rules?page=1", nil)
req.Host = "example.com:9091"
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, req)

span := findSpanByName(exporter.GetSpans(), "GET")
if span == nil {
t.Fatal("expected span named 'GET'")
Comment thread
ankurs marked this conversation as resolved.
}

attrs := spanAttrMap(span)
if v := attrs["http.request.method"]; v != "GET" {
t.Fatalf("expected http.request.method=GET, got %v", v)
}
if v := attrs["url.path"]; v != "/api/v1/rules" {
t.Fatalf("expected url.path=/api/v1/rules, got %v", v)
}
if v := attrs["url.query"]; v != "page=1" {
t.Fatalf("expected url.query=page=1, got %v", v)
}
if v := attrs["server.address"]; v != "example.com" {
t.Fatalf("expected server.address=example.com, got %v", v)
}
if v := attrs["server.port"]; v != int64(9091) {
t.Fatalf("expected server.port=9091, got %v", v)
}
if v := attrs["http.response.status_code"]; v != int64(200) {
t.Fatalf("expected http.response.status_code=200, got %v", v)
}
Comment thread
ankurs marked this conversation as resolved.
if v := attrs["url.scheme"]; v != "http" {
t.Fatalf("expected url.scheme=http, got %v", v)
}
}

func TestTracingWrapperSpanErrorStatus(t *testing.T) {
exporter, cleanup := setupTestTracer()
defer cleanup()

inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
wrapped := tracingWrapper(inner)

req := httptest.NewRequest("GET", "/api/error", nil)
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, req)

span := findSpanByName(exporter.GetSpans(), "GET")
if span == nil {
t.Fatal("expected span named 'GET'")
Comment thread
ankurs marked this conversation as resolved.
}

attrs := spanAttrMap(span)
if v := attrs["http.response.status_code"]; v != int64(500) {
t.Fatalf("expected http.response.status_code=500, got %v", v)
}
if span.Status.Code != codes.Error {
t.Fatalf("expected span status Error, got %v", span.Status.Code)
}
}

func TestTracingWrapperGatewaySpanName(t *testing.T) {
exporter, cleanup := setupTestTracer()
defer cleanup()

// Create a grpc-gateway mux with spanRouteMiddleware and a test handler.
mux := runtime.NewServeMux(runtime.WithMiddlewares(spanRouteMiddleware))
err := mux.HandlePath("GET", "/api/v1/rules/{rule_id}", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
w.WriteHeader(http.StatusOK)
})
if err != nil {
t.Fatal(err)
}

wrapped := tracingWrapper(mux)
req := httptest.NewRequest("GET", "/api/v1/rules/123", nil)
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, req)

// Pattern.String() includes wildcard spec, e.g. {rule_id=*}
wantName := "GET /api/v1/rules/{rule_id=*}"
span := findSpanByName(exporter.GetSpans(), wantName)
if span == nil {
names := make([]string, 0)
for _, s := range exporter.GetSpans() {
names = append(names, s.Name)
}
t.Fatalf("expected span named %q, got spans: %v", wantName, names)
}

attrs := spanAttrMap(span)
if v := attrs["http.route"]; v != "/api/v1/rules/{rule_id=*}" {
t.Fatalf("expected http.route=/api/v1/rules/{rule_id=*}, got %v", v)
}
}

func TestGetCustomHeaderMatcher_EmptyPrefixes(t *testing.T) {
t.Parallel()
matcher := getCustomHeaderMatcher(nil, "X-Trace-Id")
Expand Down
Loading