-
Notifications
You must be signed in to change notification settings - Fork 4
fix: use runtime.HTTPPattern for route extraction in spans #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
6755b18
0b64e49
91ad35b
f705ea1
03b739d
96f996b
567810b
ab78d2d
c810545
d0cc95c
e4c4757
be90f26
3e48e72
9e698f8
2edd9c7
13df802
f5b473c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ import ( | |||||||||||||||||||||
| "net" | ||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||
| "net/http/pprof" | ||||||||||||||||||||||
| "strconv" | ||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||
| "time" | ||||||||||||||||||||||
|
|
@@ -24,8 +25,10 @@ import ( | |||||||||||||||||||||
| "github.com/prometheus/client_golang/prometheus/promhttp" | ||||||||||||||||||||||
| "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" | ||||||||||||||||||||||
| "go.opentelemetry.io/otel" | ||||||||||||||||||||||
| "go.opentelemetry.io/otel/attribute" | ||||||||||||||||||||||
| "go.opentelemetry.io/otel/codes" | ||||||||||||||||||||||
| "go.opentelemetry.io/otel/propagation" | ||||||||||||||||||||||
| semconv "go.opentelemetry.io/otel/semconv/v1.12.0" | ||||||||||||||||||||||
| semconv "go.opentelemetry.io/otel/semconv/v1.40.0" | ||||||||||||||||||||||
| oteltrace "go.opentelemetry.io/otel/trace" | ||||||||||||||||||||||
| "google.golang.org/grpc" | ||||||||||||||||||||||
| "google.golang.org/grpc/credentials" | ||||||||||||||||||||||
|
|
@@ -169,37 +172,134 @@ func (c *cb) processConfig() { | |||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // statusRecorder wraps http.ResponseWriter to capture the final HTTP status code. | ||||||||||||||||||||||
| // It records the first status >= 200, plus 101 Switching Protocols (which is | ||||||||||||||||||||||
| // terminal). Other 1xx statuses are informational and skipped. | ||||||||||||||||||||||
| // Unwrap() is provided for http.ResponseController (Go 1.20+) to access optional | ||||||||||||||||||||||
| // interfaces (http.Flusher, http.Hijacker, etc.) from the underlying writer. | ||||||||||||||||||||||
| type statusRecorder struct { | ||||||||||||||||||||||
| http.ResponseWriter | ||||||||||||||||||||||
| status int | ||||||||||||||||||||||
| wroteHeader bool | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (sr *statusRecorder) WriteHeader(code int) { | ||||||||||||||||||||||
| if !sr.wroteHeader && (code >= 200 || code == http.StatusSwitchingProtocols) { | ||||||||||||||||||||||
| sr.status = code | ||||||||||||||||||||||
| sr.wroteHeader = true | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| sr.ResponseWriter.WriteHeader(code) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (sr *statusRecorder) Write(b []byte) (int, error) { | ||||||||||||||||||||||
| if !sr.wroteHeader { | ||||||||||||||||||||||
| sr.status = http.StatusOK | ||||||||||||||||||||||
| sr.wroteHeader = true | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return sr.ResponseWriter.Write(b) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Unwrap returns the underlying ResponseWriter so that http.ResponseController | ||||||||||||||||||||||
| // and middleware can access optional interfaces (http.Flusher, http.Hijacker, etc.). | ||||||||||||||||||||||
| func (sr *statusRecorder) Unwrap() http.ResponseWriter { | ||||||||||||||||||||||
| return sr.ResponseWriter | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // endSpan records the HTTP status code on the span, marks it as error for 5xx, and ends it. | ||||||||||||||||||||||
| func endSpan(span oteltrace.Span, rec *statusRecorder) { | ||||||||||||||||||||||
| span.SetAttributes(semconv.HTTPResponseStatusCode(rec.status)) | ||||||||||||||||||||||
| if rec.status >= 500 { | ||||||||||||||||||||||
| span.SetStatus(codes.Error, http.StatusText(rec.status)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| span.End() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // httpSpanAttributes returns the OTEL attributes for an incoming HTTP request, | ||||||||||||||||||||||
| // omitting empty-valued attributes (e.g. scheme behind a reverse proxy). | ||||||||||||||||||||||
| func httpSpanAttributes(r *http.Request) []attribute.KeyValue { | ||||||||||||||||||||||
| host, port, err := net.SplitHostPort(r.Host) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| host = r.Host | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") | ||||||||||||||||||||||
| attrs := []attribute.KeyValue{ | ||||||||||||||||||||||
| semconv.HTTPRequestMethodKey.String(r.Method), | ||||||||||||||||||||||
| semconv.URLPath(r.URL.Path), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if host != "" { | ||||||||||||||||||||||
| attrs = append(attrs, semconv.ServerAddress(host)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if port != "" { | ||||||||||||||||||||||
| if p, err := strconv.Atoi(port); err == nil { | ||||||||||||||||||||||
| attrs = append(attrs, semconv.ServerPort(p)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if r.URL.RawQuery != "" { | ||||||||||||||||||||||
| attrs = append(attrs, semconv.URLQuery(r.URL.RawQuery)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| scheme := r.URL.Scheme | ||||||||||||||||||||||
| if scheme == "" { | ||||||||||||||||||||||
| if r.TLS != nil { | ||||||||||||||||||||||
| scheme = "https" | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| scheme = "http" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| attrs = append(attrs, semconv.URLScheme(scheme)) | ||||||||||||||||||||||
|
Comment on lines
+246
to
+250
|
||||||||||||||||||||||
| } else { | |
| scheme = "http" | |
| } | |
| } | |
| attrs = append(attrs, semconv.URLScheme(scheme)) | |
| } | |
| } | |
| if scheme != "" { | |
| attrs = append(attrs, semconv.URLScheme(scheme)) | |
| } |
There was a problem hiding this comment.
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.
Copilot
AI
Mar 29, 2026
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.