From fdc826a7dd3b4eba7a0ccf7120a938f5a3921fe6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 30 Jul 2026 00:28:56 +0000 Subject: [PATCH 01/15] feat(coderd/tracing): correlate request logs and spans by session_id Read the session_id baggage member on API requests and attach it to the per-request log context and, when tracing is enabled, as a span attribute. The value is added to the log context even when tracing is disabled so logs can always be correlated by session_id, per the connection-log RFC. The session_id is validated as a 32-character hexadecimal string to guard against logging arbitrary client-controlled baggage values. Part of DEVEX-659. --- coderd/tracing/httpmw.go | 59 ++++++++++- coderd/tracing/httpmw_internal_test.go | 63 ++++++++++++ coderd/tracing/httpmw_test.go | 132 +++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 coderd/tracing/httpmw_internal_test.go diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 6c62ece2dd678..2ec54c0a50104 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -7,15 +7,23 @@ import ( "github.com/go-chi/chi/v5" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/baggage" "go.opentelemetry.io/otel/propagation" semconv "go.opentelemetry.io/otel/semconv/v1.14.0" "go.opentelemetry.io/otel/semconv/v1.14.0/httpconv" "go.opentelemetry.io/otel/semconv/v1.14.0/netconv" "go.opentelemetry.io/otel/trace" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/httpmw/patternmatcher" ) +// SessionIDBaggageKey is the W3C baggage key clients use to propagate the +// per-session correlation ID described in the connection-log RFC. The value is +// a 16-byte identifier encoded as a 32-character hexadecimal string. +const SessionIDBaggageKey = "session_id" + // Middleware adds tracing to http routes. func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Handler { // We only want to create spans on the following route patterns, however @@ -36,7 +44,20 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if tracer == nil || !re.MatchString(r.URL.Path) { + if !re.MatchString(r.URL.Path) { + next.ServeHTTP(rw, r) + return + } + + // Read the session_id from baggage and add it to the log context. + // This is done even when tracing is disabled so that logs can + // always be correlated by session_id. + sessionID := sessionIDFromHeaders(r.Header) + if sessionID != "" { + r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID))) + } + + if tracer == nil { next.ServeHTTP(rw, r) return } @@ -46,6 +67,10 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.RequestURI)) defer span.End() + if sessionID != "" { + span.SetAttributes(attribute.String("session_id", sessionID)) + } + sw, ok := rw.(*StatusWriter) if !ok { panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw)) @@ -59,6 +84,38 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } +// sessionIDFromHeaders extracts and validates the session_id baggage member +// from the request headers. It returns an empty string when the member is +// absent or malformed. Extraction uses an explicit baggage propagator so it +// does not depend on the globally configured text map propagator. +func sessionIDFromHeaders(h http.Header) string { + ctx := propagation.Baggage{}.Extract(context.Background(), propagation.HeaderCarrier(h)) + id := baggage.FromContext(ctx).Member(SessionIDBaggageKey).Value() + if !validSessionID(id) { + return "" + } + return id +} + +// validSessionID reports whether s is a 32-character hexadecimal string, the +// encoding the RFC mandates for the 16-byte session ID. Validating guards +// against logging arbitrary client-controlled baggage values. +func validSessionID(s string) bool { + if len(s) != 32 { + return false + } + for _, c := range s { + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + case c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} + // StartHTTPSpan starts a span, propagating inbound trace context and writing // X-Trace-ID/X-Span-ID response headers. The caller must end the span. func StartHTTPSpan(tracer trace.Tracer, rw http.ResponseWriter, r *http.Request, name string) (*http.Request, trace.Span) { diff --git a/coderd/tracing/httpmw_internal_test.go b/coderd/tracing/httpmw_internal_test.go new file mode 100644 index 0000000000000..c5983195706ea --- /dev/null +++ b/coderd/tracing/httpmw_internal_test.go @@ -0,0 +1,63 @@ +package tracing + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidSessionID(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + id string + valid bool + }{ + {"LowerHex", "0123456789abcdef0123456789abcdef", true}, + {"UpperHex", "0123456789ABCDEF0123456789ABCDEF", true}, + {"Empty", "", false}, + {"TooShort", "0123456789abcdef0123456789abcde", false}, + {"TooLong", "0123456789abcdef0123456789abcdef0", false}, + {"NonHex", "0123456789abcdef0123456789abcdeg", false}, + {"Uuid", "0123456789ab-cdef-0123456789abcde", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.valid, validSessionID(c.id)) + }) + } +} + +func TestSessionIDFromHeaders(t *testing.T) { + t.Parallel() + + const validID = "0123456789abcdef0123456789abcdef" + + cases := []struct { + name string + baggage string + want string + }{ + {"Valid", "session_id=" + validID, validID}, + {"WithOtherMembers", "foo=bar,session_id=" + validID + ",baz=qux", validID}, + {"Missing", "foo=bar", ""}, + {"NoHeader", "", ""}, + {"Malformed", "session_id=not-a-hex-value", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + h := http.Header{} + if c.baggage != "" { + h.Set("baggage", c.baggage) + } + require.Equal(t, c.want, sessionIDFromHeaders(h)) + }) + } +} diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 0f3611717e75b..de1c4d7d7921e 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -4,15 +4,19 @@ import ( "context" "net/http" "net/http/httptest" + "slices" "strings" + "sync" "sync/atomic" "testing" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/testutil" ) @@ -43,6 +47,134 @@ func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOp return ctx, tracing.NoopSpan } +// recordingSpan wraps a noop span and records the attributes set on it so +// tests can assert on span attributes. +type recordingSpan struct { + trace.Span + mu sync.Mutex + attrs []attribute.KeyValue +} + +func (s *recordingSpan) SetAttributes(kv ...attribute.KeyValue) { + s.mu.Lock() + defer s.mu.Unlock() + s.attrs = append(s.attrs, kv...) +} + +func (s *recordingSpan) attributes() []attribute.KeyValue { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Clone(s.attrs) +} + +// recordingTracer is a trace.TracerProvider/Tracer that hands out a single +// recordingSpan. +type recordingTracer struct { + noop.TracerProvider + noopTracer + span *recordingSpan +} + +func (t *recordingTracer) Tracer(_ string, _ ...trace.TracerOption) trace.Tracer { + return t +} + +func (t *recordingTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOption) (context.Context, trace.Span) { + return ctx, t.span +} + +const testSessionID = "0123456789abcdef0123456789abcdef" + +func Test_Middleware_SessionID(t *testing.T) { + t.Parallel() + + // requestFields serves a request through the middleware and returns the + // fields logged by a downstream handler using the request context. + requestFields := func(t *testing.T, tp trace.TracerProvider, header string) []slog.Field { + t.Helper() + + sink := testutil.NewFakeSink(t) + logger := sink.Logger() + + handler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Logging with the request context surfaces any fields the + // middleware added via slog.With. + logger.Info(r.Context(), "downstream handler invoked") + rw.WriteHeader(http.StatusNoContent) + }) + + rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()} + r := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil) + if header != "" { + r.Header.Set("baggage", header) + } + + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chi.NewRouteContext()) + r = r.WithContext(ctx) + + tracing.Middleware(tp)(handler).ServeHTTP(rw, r) + + entries := sink.Entries(func(e slog.SinkEntry) bool { + return e.Message == "downstream handler invoked" + }) + require.Len(t, entries, 1) + return entries[0].Fields + } + + fieldValue := func(fields []slog.Field, name string) (any, bool) { + for _, f := range fields { + if f.Name == name { + return f.Value, true + } + } + return nil, false + } + + t.Run("TracingEnabled", func(t *testing.T) { + t.Parallel() + + tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "session_id="+testSessionID) + + val, ok := fieldValue(fields, "session_id") + require.True(t, ok, "session_id should be on the log context") + require.Equal(t, testSessionID, val) + + require.Contains(t, tp.span.attributes(), attribute.String("session_id", testSessionID)) + }) + + t.Run("TracingDisabled", func(t *testing.T) { + t.Parallel() + + // A nil tracer provider disables span creation, but the session_id + // must still land on the log context. + fields := requestFields(t, nil, "session_id="+testSessionID) + + val, ok := fieldValue(fields, "session_id") + require.True(t, ok, "session_id should be on the log context even when tracing is disabled") + require.Equal(t, testSessionID, val) + }) + + t.Run("NoBaggage", func(t *testing.T) { + t.Parallel() + + fields := requestFields(t, nil, "") + _, ok := fieldValue(fields, "session_id") + require.False(t, ok, "session_id should be absent when no baggage is sent") + }) + + t.Run("MalformedBaggage", func(t *testing.T) { + t.Parallel() + + tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "session_id=not-a-valid-session-id") + + _, ok := fieldValue(fields, "session_id") + require.False(t, ok, "malformed session_id should be ignored") + require.NotContains(t, tp.span.attributes(), attribute.String("session_id", "not-a-valid-session-id")) + }) +} + func Test_Middleware(t *testing.T) { t.Parallel() From a929df685c76b0238319bf23caee4d302cb06ff2 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 30 Jul 2026 03:04:21 +0000 Subject: [PATCH 02/15] refactor(coderd/tracing): use SessionIDBaggageKey const for the baggage key Reference the exported SessionIDBaggageKey constant when reading the baggage member and when constructing baggage headers in tests, instead of repeating the string literal. The emitted slog field and span attribute names remain snake_case string literals ("session_id"), as required by the slog field-name lint rule. --- coderd/tracing/httpmw.go | 3 ++- coderd/tracing/httpmw_internal_test.go | 6 +++--- coderd/tracing/httpmw_test.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 2ec54c0a50104..745d86b8a8bd5 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -51,7 +51,8 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han // Read the session_id from baggage and add it to the log context. // This is done even when tracing is disabled so that logs can - // always be correlated by session_id. + // always be correlated by session_id. The field name is written as + // a literal because slog field names must be snake_case literals. sessionID := sessionIDFromHeaders(r.Header) if sessionID != "" { r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID))) diff --git a/coderd/tracing/httpmw_internal_test.go b/coderd/tracing/httpmw_internal_test.go index c5983195706ea..abc82b8888bb1 100644 --- a/coderd/tracing/httpmw_internal_test.go +++ b/coderd/tracing/httpmw_internal_test.go @@ -42,11 +42,11 @@ func TestSessionIDFromHeaders(t *testing.T) { baggage string want string }{ - {"Valid", "session_id=" + validID, validID}, - {"WithOtherMembers", "foo=bar,session_id=" + validID + ",baz=qux", validID}, + {"Valid", SessionIDBaggageKey + "=" + validID, validID}, + {"WithOtherMembers", "foo=bar," + SessionIDBaggageKey + "=" + validID + ",baz=qux", validID}, {"Missing", "foo=bar", ""}, {"NoHeader", "", ""}, - {"Malformed", "session_id=not-a-hex-value", ""}, + {"Malformed", SessionIDBaggageKey + "=not-a-hex-value", ""}, } for _, c := range cases { diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index de1c4d7d7921e..331f8aff05e2f 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -134,7 +134,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Parallel() tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, "session_id="+testSessionID) + fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"="+testSessionID) val, ok := fieldValue(fields, "session_id") require.True(t, ok, "session_id should be on the log context") @@ -148,7 +148,7 @@ func Test_Middleware_SessionID(t *testing.T) { // A nil tracer provider disables span creation, but the session_id // must still land on the log context. - fields := requestFields(t, nil, "session_id="+testSessionID) + fields := requestFields(t, nil, tracing.SessionIDBaggageKey+"="+testSessionID) val, ok := fieldValue(fields, "session_id") require.True(t, ok, "session_id should be on the log context even when tracing is disabled") @@ -167,7 +167,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Parallel() tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, "session_id=not-a-valid-session-id") + fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"=not-a-valid-session-id") _, ok := fieldValue(fields, "session_id") require.False(t, ok, "malformed session_id should be ignored") From 7437debc26081dd9b3f54a50f8f7eaf57b63c7e0 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 18:07:45 +0000 Subject: [PATCH 03/15] docs: remove slog field name waffle --- coderd/tracing/httpmw.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 745d86b8a8bd5..2ec54c0a50104 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -51,8 +51,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han // Read the session_id from baggage and add it to the log context. // This is done even when tracing is disabled so that logs can - // always be correlated by session_id. The field name is written as - // a literal because slog field names must be snake_case literals. + // always be correlated by session_id. sessionID := sessionIDFromHeaders(r.Header) if sessionID != "" { r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID))) From 54d8d4e779c850b31904c9ffeb34cf5e81a7e639 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 00:37:31 +0000 Subject: [PATCH 04/15] fix(coderd/tracing): only accept lowercase hex session IDs The connection-log RFC was updated to mandate lowercase hexadecimal for session IDs so that case-sensitive searches correlate reliably. Tighten validSessionID to reject uppercase and update the tests accordingly. --- coderd/tracing/httpmw.go | 9 +++++---- coderd/tracing/httpmw_internal_test.go | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 2ec54c0a50104..54d1444cf08ed 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -97,9 +97,11 @@ func sessionIDFromHeaders(h http.Header) string { return id } -// validSessionID reports whether s is a 32-character hexadecimal string, the -// encoding the RFC mandates for the 16-byte session ID. Validating guards -// against logging arbitrary client-controlled baggage values. +// validSessionID reports whether s is a 32-character lowercase hexadecimal +// string, the encoding the RFC mandates for the 16-byte session ID. Only +// lowercase is accepted so that case-sensitive searches correlate reliably. +// Validating also guards against logging arbitrary client-controlled baggage +// values. func validSessionID(s string) bool { if len(s) != 32 { return false @@ -108,7 +110,6 @@ func validSessionID(s string) bool { switch { case c >= '0' && c <= '9': case c >= 'a' && c <= 'f': - case c >= 'A' && c <= 'F': default: return false } diff --git a/coderd/tracing/httpmw_internal_test.go b/coderd/tracing/httpmw_internal_test.go index abc82b8888bb1..8ced932f44f2b 100644 --- a/coderd/tracing/httpmw_internal_test.go +++ b/coderd/tracing/httpmw_internal_test.go @@ -16,7 +16,8 @@ func TestValidSessionID(t *testing.T) { valid bool }{ {"LowerHex", "0123456789abcdef0123456789abcdef", true}, - {"UpperHex", "0123456789ABCDEF0123456789ABCDEF", true}, + {"UpperHex", "0123456789ABCDEF0123456789ABCDEF", false}, + {"MixedCase", "0123456789abcdef0123456789ABCDEF", false}, {"Empty", "", false}, {"TooShort", "0123456789abcdef0123456789abcde", false}, {"TooLong", "0123456789abcdef0123456789abcdef0", false}, From bfe48cf9405edc080e798fccb139923ca22fa840 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 17:07:11 +0000 Subject: [PATCH 05/15] test(coderd/tracing): couple session_id field names to the baggage key Add a test asserting the baggage key, log field name, and span attribute name all equal "session_id". slog field names must be snake_case string literals, so the log field and span attribute cannot reference the SessionIDBaggageKey const directly; this test guards against them drifting apart and silently breaking log/trace correlation (PR review P2). --- coderd/tracing/httpmw_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 331f8aff05e2f..13a54cc6d78fa 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -173,6 +173,26 @@ func Test_Middleware_SessionID(t *testing.T) { require.False(t, ok, "malformed session_id should be ignored") require.NotContains(t, tp.span.attributes(), attribute.String("session_id", "not-a-valid-session-id")) }) + + // FieldNamesMatchBaggageKey pins the baggage key, the log field name, and + // the span attribute name to the same value. slog field names must be + // snake_case string literals, so the log field and span attribute cannot + // reference SessionIDBaggageKey directly; this test guards against the + // three drifting apart and silently breaking log/trace correlation. + t.Run("FieldNamesMatchBaggageKey", func(t *testing.T) { + t.Parallel() + + require.Equal(t, "session_id", tracing.SessionIDBaggageKey) + + tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"="+testSessionID) + + _, ok := fieldValue(fields, tracing.SessionIDBaggageKey) + require.True(t, ok, "log field name must match the baggage key") + require.Contains(t, tp.span.attributes(), + attribute.String(tracing.SessionIDBaggageKey, testSessionID), + "span attribute name must match the baggage key") + }) } func Test_Middleware(t *testing.T) { From 79968e250ecfe8019b7f946aad10c6e05b79612d Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 17:14:07 +0000 Subject: [PATCH 06/15] test(coderd/tracing): assert session_id is skipped on non-matching routes The middleware extracts session_id only on matched API/app routes. Add a subtest that sends well-formed baggage to a non-matching path (/index.html) and asserts session_id is absent from both the log fields and the span, so a regression that logged client-controlled baggage on every request would be caught (PR review P3). --- coderd/tracing/httpmw_test.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 13a54cc6d78fa..023dd9c3b31fb 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -90,7 +90,7 @@ func Test_Middleware_SessionID(t *testing.T) { // requestFields serves a request through the middleware and returns the // fields logged by a downstream handler using the request context. - requestFields := func(t *testing.T, tp trace.TracerProvider, header string) []slog.Field { + requestFields := func(t *testing.T, tp trace.TracerProvider, path, header string) []slog.Field { t.Helper() sink := testutil.NewFakeSink(t) @@ -104,7 +104,7 @@ func Test_Middleware_SessionID(t *testing.T) { }) rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()} - r := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil) + r := httptest.NewRequest(http.MethodGet, path, nil) if header != "" { r.Header.Set("baggage", header) } @@ -134,7 +134,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Parallel() tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"="+testSessionID) + fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) val, ok := fieldValue(fields, "session_id") require.True(t, ok, "session_id should be on the log context") @@ -148,7 +148,7 @@ func Test_Middleware_SessionID(t *testing.T) { // A nil tracer provider disables span creation, but the session_id // must still land on the log context. - fields := requestFields(t, nil, tracing.SessionIDBaggageKey+"="+testSessionID) + fields := requestFields(t, nil, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) val, ok := fieldValue(fields, "session_id") require.True(t, ok, "session_id should be on the log context even when tracing is disabled") @@ -158,7 +158,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Run("NoBaggage", func(t *testing.T) { t.Parallel() - fields := requestFields(t, nil, "") + fields := requestFields(t, nil, "/api/v2/workspaces", "") _, ok := fieldValue(fields, "session_id") require.False(t, ok, "session_id should be absent when no baggage is sent") }) @@ -167,13 +167,28 @@ func Test_Middleware_SessionID(t *testing.T) { t.Parallel() tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"=not-a-valid-session-id") + fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"=not-a-valid-session-id") _, ok := fieldValue(fields, "session_id") require.False(t, ok, "malformed session_id should be ignored") require.NotContains(t, tp.span.attributes(), attribute.String("session_id", "not-a-valid-session-id")) }) + t.Run("NonMatchingRoute", func(t *testing.T) { + t.Parallel() + + // The middleware only runs on matched API/app routes. Static and + // asset routes must not extract session_id, even from well-formed + // baggage, so client-controlled baggage is never logged for every + // request. + tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "/index.html", tracing.SessionIDBaggageKey+"="+testSessionID) + + _, ok := fieldValue(fields, "session_id") + require.False(t, ok, "session_id must not be logged on a non-matching route") + require.NotContains(t, tp.span.attributes(), attribute.String("session_id", testSessionID)) + }) + // FieldNamesMatchBaggageKey pins the baggage key, the log field name, and // the span attribute name to the same value. slog field names must be // snake_case string literals, so the log field and span attribute cannot @@ -185,7 +200,7 @@ func Test_Middleware_SessionID(t *testing.T) { require.Equal(t, "session_id", tracing.SessionIDBaggageKey) tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, tracing.SessionIDBaggageKey+"="+testSessionID) + fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) _, ok := fieldValue(fields, tracing.SessionIDBaggageKey) require.True(t, ok, "log field name must match the baggage key") From 2397161e0ca2218a48ac1e03fcbd8f92e5cdcbe5 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 17:19:32 +0000 Subject: [PATCH 07/15] test(coderd/tracing): assert absence of any session_id span attribute The negative span assertions checked only that a specific value was not set, so a bug setting session_id to a different derived value would pass. Scan the span attributes for any key equal to session_id and assert its absence in the malformed-baggage and non-matching-route cases (PR review P3). --- coderd/tracing/httpmw_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 023dd9c3b31fb..cd9a8e5ef6629 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -130,6 +130,15 @@ func Test_Middleware_SessionID(t *testing.T) { return nil, false } + hasAttrKey := func(attrs []attribute.KeyValue, key string) bool { + for _, a := range attrs { + if string(a.Key) == key { + return true + } + } + return false + } + t.Run("TracingEnabled", func(t *testing.T) { t.Parallel() @@ -171,7 +180,8 @@ func Test_Middleware_SessionID(t *testing.T) { _, ok := fieldValue(fields, "session_id") require.False(t, ok, "malformed session_id should be ignored") - require.NotContains(t, tp.span.attributes(), attribute.String("session_id", "not-a-valid-session-id")) + require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), + "no session_id attribute should be set for malformed baggage") }) t.Run("NonMatchingRoute", func(t *testing.T) { @@ -186,7 +196,8 @@ func Test_Middleware_SessionID(t *testing.T) { _, ok := fieldValue(fields, "session_id") require.False(t, ok, "session_id must not be logged on a non-matching route") - require.NotContains(t, tp.span.attributes(), attribute.String("session_id", testSessionID)) + require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), + "no session_id attribute should be set on a non-matching route") }) // FieldNamesMatchBaggageKey pins the baggage key, the log field name, and From 6d280489b2776077a24bc9e30f60f310002db461 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 17:31:06 +0000 Subject: [PATCH 08/15] refactor(coderd/tracing): validate session IDs with encoding/hex Use hex.DecodeString to check the value is 16 bytes of hex, matching the codebase convention, then require hex.EncodeToString(b) == s so only the canonical lowercase encoding is accepted (hex.DecodeString also accepts upper-case) (PR review nit). --- coderd/tracing/httpmw.go | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 54d1444cf08ed..30dd1b1021db5 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -2,6 +2,7 @@ package tracing import ( "context" + "encoding/hex" "fmt" "net/http" @@ -98,23 +99,18 @@ func sessionIDFromHeaders(h http.Header) string { } // validSessionID reports whether s is a 32-character lowercase hexadecimal -// string, the encoding the RFC mandates for the 16-byte session ID. Only -// lowercase is accepted so that case-sensitive searches correlate reliably. -// Validating also guards against logging arbitrary client-controlled baggage -// values. +// string (a 16-byte value), the encoding the RFC mandates for the session ID. +// Only lowercase is accepted so that case-sensitive searches correlate +// reliably. Validating also guards against logging arbitrary client-controlled +// baggage values. func validSessionID(s string) bool { - if len(s) != 32 { + b, err := hex.DecodeString(s) + if err != nil || len(b) != 16 { return false } - for _, c := range s { - switch { - case c >= '0' && c <= '9': - case c >= 'a' && c <= 'f': - default: - return false - } - } - return true + // hex.DecodeString also accepts upper-case, so require the canonical + // lowercase encoding. + return hex.EncodeToString(b) == s } // StartHTTPSpan starts a span, propagating inbound trace context and writing From 948b1db06322981556aa04d561af1df7ecfe689c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 19:14:17 +0000 Subject: [PATCH 09/15] test(coderd/tracing): consolidate fake tracer and cover empty session ID Fold recordingTracer into fakeTracer via an optional recording span, and add a tracing-enabled + no-baggage subtest that pins the branch which must not set an empty session_id span attribute or log field. --- coderd/tracing/httpmw_test.go | 46 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index cd9a8e5ef6629..fbb546cfaff3d 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -29,6 +29,10 @@ type fakeTracer struct { noop.TracerProvider noopTracer startCalled atomic.Int64 + // span, when set, is returned from Start so tests can assert on the + // attributes the middleware records. When nil, Start returns + // tracing.NoopSpan. + span *recordingSpan } var ( @@ -44,6 +48,9 @@ func (f *fakeTracer) Tracer(_ string, _ ...trace.TracerOption) trace.Tracer { // Start implements trace.Tracer. func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOption) (context.Context, trace.Span) { f.startCalled.Add(1) + if f.span != nil { + return ctx, f.span + } return ctx, tracing.NoopSpan } @@ -67,22 +74,6 @@ func (s *recordingSpan) attributes() []attribute.KeyValue { return slices.Clone(s.attrs) } -// recordingTracer is a trace.TracerProvider/Tracer that hands out a single -// recordingSpan. -type recordingTracer struct { - noop.TracerProvider - noopTracer - span *recordingSpan -} - -func (t *recordingTracer) Tracer(_ string, _ ...trace.TracerOption) trace.Tracer { - return t -} - -func (t *recordingTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOption) (context.Context, trace.Span) { - return ctx, t.span -} - const testSessionID = "0123456789abcdef0123456789abcdef" func Test_Middleware_SessionID(t *testing.T) { @@ -142,7 +133,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Run("TracingEnabled", func(t *testing.T) { t.Parallel() - tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) val, ok := fieldValue(fields, "session_id") @@ -152,6 +143,21 @@ func Test_Middleware_SessionID(t *testing.T) { require.Contains(t, tp.span.attributes(), attribute.String("session_id", testSessionID)) }) + t.Run("TracingEnabledNoBaggage", func(t *testing.T) { + t.Parallel() + + // With tracing on but no baggage, the session ID is empty and the + // middleware must not set an empty session_id span attribute or log + // field. + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "/api/v2/workspaces", "") + + _, ok := fieldValue(fields, "session_id") + require.False(t, ok, "session_id should be absent when no baggage is sent") + require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), + "no session_id attribute should be set when no baggage is sent") + }) + t.Run("TracingDisabled", func(t *testing.T) { t.Parallel() @@ -175,7 +181,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Run("MalformedBaggage", func(t *testing.T) { t.Parallel() - tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"=not-a-valid-session-id") _, ok := fieldValue(fields, "session_id") @@ -191,7 +197,7 @@ func Test_Middleware_SessionID(t *testing.T) { // asset routes must not extract session_id, even from well-formed // baggage, so client-controlled baggage is never logged for every // request. - tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/index.html", tracing.SessionIDBaggageKey+"="+testSessionID) _, ok := fieldValue(fields, "session_id") @@ -210,7 +216,7 @@ func Test_Middleware_SessionID(t *testing.T) { require.Equal(t, "session_id", tracing.SessionIDBaggageKey) - tp := &recordingTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) _, ok := fieldValue(fields, tracing.SessionIDBaggageKey) From 3bac44f3f13868cc348d2bb0c27e34bfe075bc4e Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 00:03:55 +0000 Subject: [PATCH 10/15] feat: rename session_id to client_session_id --- coderd/tracing/httpmw.go | 12 ++++----- coderd/tracing/httpmw_test.go | 46 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 30dd1b1021db5..0c13391d76777 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -23,7 +23,7 @@ import ( // SessionIDBaggageKey is the W3C baggage key clients use to propagate the // per-session correlation ID described in the connection-log RFC. The value is // a 16-byte identifier encoded as a 32-character hexadecimal string. -const SessionIDBaggageKey = "session_id" +const SessionIDBaggageKey = "client_session_id" // Middleware adds tracing to http routes. func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Handler { @@ -50,12 +50,12 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han return } - // Read the session_id from baggage and add it to the log context. + // Read the client_session_id from baggage and add it to the log context. // This is done even when tracing is disabled so that logs can - // always be correlated by session_id. + // always be correlated by client_session_id. sessionID := sessionIDFromHeaders(r.Header) if sessionID != "" { - r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID))) + r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID))) } if tracer == nil { @@ -69,7 +69,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han defer span.End() if sessionID != "" { - span.SetAttributes(attribute.String("session_id", sessionID)) + span.SetAttributes(attribute.String("client_session_id", sessionID)) } sw, ok := rw.(*StatusWriter) @@ -85,7 +85,7 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } -// sessionIDFromHeaders extracts and validates the session_id baggage member +// sessionIDFromHeaders extracts and validates the client_session_id baggage member // from the request headers. It returns an empty string when the member is // absent or malformed. Extraction uses an explicit baggage propagator so it // does not depend on the globally configured text map propagator. diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index fbb546cfaff3d..819eba97ed0f7 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -136,37 +136,37 @@ func Test_Middleware_SessionID(t *testing.T) { tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) - val, ok := fieldValue(fields, "session_id") - require.True(t, ok, "session_id should be on the log context") + val, ok := fieldValue(fields, "client_session_id") + require.True(t, ok, "client_session_id should be on the log context") require.Equal(t, testSessionID, val) - require.Contains(t, tp.span.attributes(), attribute.String("session_id", testSessionID)) + require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID)) }) t.Run("TracingEnabledNoBaggage", func(t *testing.T) { t.Parallel() // With tracing on but no baggage, the session ID is empty and the - // middleware must not set an empty session_id span attribute or log + // middleware must not set an empty client_session_id span attribute or log // field. tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", "") - _, ok := fieldValue(fields, "session_id") - require.False(t, ok, "session_id should be absent when no baggage is sent") - require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), - "no session_id attribute should be set when no baggage is sent") + _, ok := fieldValue(fields, "client_session_id") + require.False(t, ok, "client_session_id should be absent when no baggage is sent") + require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"), + "no client_session_id attribute should be set when no baggage is sent") }) t.Run("TracingDisabled", func(t *testing.T) { t.Parallel() - // A nil tracer provider disables span creation, but the session_id + // A nil tracer provider disables span creation, but the client_session_id // must still land on the log context. fields := requestFields(t, nil, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) - val, ok := fieldValue(fields, "session_id") - require.True(t, ok, "session_id should be on the log context even when tracing is disabled") + val, ok := fieldValue(fields, "client_session_id") + require.True(t, ok, "client_session_id should be on the log context even when tracing is disabled") require.Equal(t, testSessionID, val) }) @@ -174,8 +174,8 @@ func Test_Middleware_SessionID(t *testing.T) { t.Parallel() fields := requestFields(t, nil, "/api/v2/workspaces", "") - _, ok := fieldValue(fields, "session_id") - require.False(t, ok, "session_id should be absent when no baggage is sent") + _, ok := fieldValue(fields, "client_session_id") + require.False(t, ok, "client_session_id should be absent when no baggage is sent") }) t.Run("MalformedBaggage", func(t *testing.T) { @@ -184,26 +184,26 @@ func Test_Middleware_SessionID(t *testing.T) { tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"=not-a-valid-session-id") - _, ok := fieldValue(fields, "session_id") - require.False(t, ok, "malformed session_id should be ignored") - require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), - "no session_id attribute should be set for malformed baggage") + _, ok := fieldValue(fields, "client_session_id") + require.False(t, ok, "malformed client_session_id should be ignored") + require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"), + "no client_session_id attribute should be set for malformed baggage") }) t.Run("NonMatchingRoute", func(t *testing.T) { t.Parallel() // The middleware only runs on matched API/app routes. Static and - // asset routes must not extract session_id, even from well-formed + // asset routes must not extract client_session_id, even from well-formed // baggage, so client-controlled baggage is never logged for every // request. tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/index.html", tracing.SessionIDBaggageKey+"="+testSessionID) - _, ok := fieldValue(fields, "session_id") - require.False(t, ok, "session_id must not be logged on a non-matching route") - require.False(t, hasAttrKey(tp.span.attributes(), "session_id"), - "no session_id attribute should be set on a non-matching route") + _, ok := fieldValue(fields, "client_session_id") + require.False(t, ok, "client_session_id must not be logged on a non-matching route") + require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"), + "no client_session_id attribute should be set on a non-matching route") }) // FieldNamesMatchBaggageKey pins the baggage key, the log field name, and @@ -214,7 +214,7 @@ func Test_Middleware_SessionID(t *testing.T) { t.Run("FieldNamesMatchBaggageKey", func(t *testing.T) { t.Parallel() - require.Equal(t, "session_id", tracing.SessionIDBaggageKey) + require.Equal(t, "client_session_id", tracing.SessionIDBaggageKey) tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} fields := requestFields(t, tp, "/api/v2/workspaces", tracing.SessionIDBaggageKey+"="+testSessionID) From fdfa486498bd7bf959db7d7cf7ce4c1a6fa4ac27 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 17 Aug 2026 21:37:44 +0000 Subject: [PATCH 11/15] test(coderd/tracing): rename MalformedBaggage to MalformedSessionID The baggage is well-formed; it is the session ID value that is malformed. Addresses review feedback on #27671. --- coderd/tracing/httpmw_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 819eba97ed0f7..41f8cf9987460 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -178,7 +178,7 @@ func Test_Middleware_SessionID(t *testing.T) { require.False(t, ok, "client_session_id should be absent when no baggage is sent") }) - t.Run("MalformedBaggage", func(t *testing.T) { + t.Run("MalformedSessionID", func(t *testing.T) { t.Parallel() tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} @@ -187,7 +187,7 @@ func Test_Middleware_SessionID(t *testing.T) { _, ok := fieldValue(fields, "client_session_id") require.False(t, ok, "malformed client_session_id should be ignored") require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"), - "no client_session_id attribute should be set for malformed baggage") + "no client_session_id attribute should be set for a malformed session ID") }) t.Run("NonMatchingRoute", func(t *testing.T) { From d48cc49c0be2ec434cc8c531dbc42747f7497cd7 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 17 Aug 2026 21:50:40 +0000 Subject: [PATCH 12/15] refactor(coderd/tracing): always span via a no-op tracer The tracer provider is never nil in production (coderd defaults it to a no-op provider when tracing is disabled), so drop the tracer == nil special case. Default a nil provider to no-op and always start the span and set the client_session_id attribute; the no-op tracer discards the span. Addresses review feedback on #27671. --- coderd/tracing/httpmw.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 0c13391d76777..006415c9e1598 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -15,6 +15,7 @@ import ( "go.opentelemetry.io/otel/semconv/v1.14.0/httpconv" "go.opentelemetry.io/otel/semconv/v1.14.0/netconv" "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/httpmw/patternmatcher" @@ -38,10 +39,10 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han "/external-auth/*/callback", }.MustCompile() - var tracer trace.Tracer - if tracerProvider != nil { - tracer = tracerProvider.Tracer(TracerName) + if tracerProvider == nil { + tracerProvider = noop.NewTracerProvider() } + tracer := tracerProvider.Tracer(TracerName) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { @@ -58,11 +59,6 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID))) } - if tracer == nil { - next.ServeHTTP(rw, r) - return - } - // Start span with default span name. Span name will be updated to // "method route" format once request finishes. r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.RequestURI)) From 6f262e8917feb3f5c9b291e19292d1e5579a84a6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 17 Aug 2026 22:11:39 +0000 Subject: [PATCH 13/15] feat(coderd/tracing): fall back to client_session_id query param Browser WebSocket clients such as the web terminal PTY cannot set arbitrary baggage headers. Extend the tracing middleware to read the client_session_id from baggage first, then fall back to the client_session_id query parameter. The query value passes the same lowercase 32-hex validation, and baggage takes precedence when both are present. This lands the session ID on both the log context and the trace span for PTY requests. --- coderd/tracing/httpmw.go | 23 +++++++++++++--- coderd/tracing/httpmw_test.go | 49 ++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 006415c9e1598..487d270861842 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -51,10 +51,10 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han return } - // Read the client_session_id from baggage and add it to the log context. - // This is done even when tracing is disabled so that logs can + // Read the client_session_id from the request and add it to the log + // context. This is done even when tracing is disabled so that logs can // always be correlated by client_session_id. - sessionID := sessionIDFromHeaders(r.Header) + sessionID := sessionIDFromRequest(r) if sessionID != "" { r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID))) } @@ -81,6 +81,23 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han } } +// sessionIDFromRequest extracts and validates the client_session_id from the +// request. It prefers the W3C baggage member and falls back to the +// client_session_id query parameter. The query parameter fallback exists +// because browser WebSocket clients (such as the web terminal PTY) cannot set +// arbitrary baggage headers. It returns an empty string when neither source +// provides a valid value. +func sessionIDFromRequest(r *http.Request) string { + if id := sessionIDFromHeaders(r.Header); id != "" { + return id + } + id := r.URL.Query().Get(SessionIDBaggageKey) + if !validSessionID(id) { + return "" + } + return id +} + // sessionIDFromHeaders extracts and validates the client_session_id baggage member // from the request headers. It returns an empty string when the member is // absent or malformed. Extraction uses an explicit baggage propagator so it diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 41f8cf9987460..a4677a2534264 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -190,15 +190,58 @@ func Test_Middleware_SessionID(t *testing.T) { "no client_session_id attribute should be set for a malformed session ID") }) + t.Run("QueryParameter", func(t *testing.T) { + t.Parallel() + + // Browser WebSocket clients (such as the web terminal PTY) cannot set + // baggage headers, so the middleware falls back to the + // client_session_id query parameter. + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"="+testSessionID, "") + + val, ok := fieldValue(fields, "client_session_id") + require.True(t, ok, "client_session_id from the query parameter should be on the log context") + require.Equal(t, testSessionID, val) + require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID)) + }) + + t.Run("BaggageTakesPrecedence", func(t *testing.T) { + t.Parallel() + + // When both baggage and the query parameter are present, baggage wins. + const querySessionID = "fedcba9876543210fedcba9876543210" + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, + "/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"="+querySessionID, + tracing.SessionIDBaggageKey+"="+testSessionID) + + val, ok := fieldValue(fields, "client_session_id") + require.True(t, ok, "client_session_id should be on the log context") + require.Equal(t, testSessionID, val, "baggage should take precedence over the query parameter") + require.Contains(t, tp.span.attributes(), attribute.String("client_session_id", testSessionID)) + }) + + t.Run("MalformedQuerySessionID", func(t *testing.T) { + t.Parallel() + + tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} + fields := requestFields(t, tp, "/api/v2/workspaces?"+tracing.SessionIDBaggageKey+"=not-a-valid-session-id", "") + + _, ok := fieldValue(fields, "client_session_id") + require.False(t, ok, "malformed client_session_id query parameter should be ignored") + require.False(t, hasAttrKey(tp.span.attributes(), "client_session_id"), + "no client_session_id attribute should be set for a malformed query session ID") + }) + t.Run("NonMatchingRoute", func(t *testing.T) { t.Parallel() // The middleware only runs on matched API/app routes. Static and // asset routes must not extract client_session_id, even from well-formed - // baggage, so client-controlled baggage is never logged for every - // request. + // baggage or a well-formed query parameter, so client-controlled + // values are never logged for every request. tp := &fakeTracer{span: &recordingSpan{Span: tracing.NoopSpan}} - fields := requestFields(t, tp, "/index.html", tracing.SessionIDBaggageKey+"="+testSessionID) + fields := requestFields(t, tp, "/index.html?"+tracing.SessionIDBaggageKey+"="+testSessionID, tracing.SessionIDBaggageKey+"="+testSessionID) _, ok := fieldValue(fields, "client_session_id") require.False(t, ok, "client_session_id must not be logged on a non-matching route") From c349e92c3739cab37afdcb4c673b70275ec19ddc Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 18 Aug 2026 22:30:56 +0000 Subject: [PATCH 14/15] test(coderd/tracing): drop unnecessary mutex from recordingSpan Each subtest builds its own fakeTracer and recordingSpan, and the middleware handles each request synchronously on a single goroutine. The span's attributes are read only after ServeHTTP returns on the same goroutine, so there is a strict happens-before ordering and no shared access. The sync.Mutex and defensive slices.Clone are therefore unnecessary. Verified with go test -race. --- coderd/tracing/httpmw_test.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index a4677a2534264..898fadee9b29e 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -4,9 +4,7 @@ import ( "context" "net/http" "net/http/httptest" - "slices" "strings" - "sync" "sync/atomic" "testing" @@ -58,20 +56,15 @@ func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOp // tests can assert on span attributes. type recordingSpan struct { trace.Span - mu sync.Mutex attrs []attribute.KeyValue } func (s *recordingSpan) SetAttributes(kv ...attribute.KeyValue) { - s.mu.Lock() - defer s.mu.Unlock() s.attrs = append(s.attrs, kv...) } func (s *recordingSpan) attributes() []attribute.KeyValue { - s.mu.Lock() - defer s.mu.Unlock() - return slices.Clone(s.attrs) + return s.attrs } const testSessionID = "0123456789abcdef0123456789abcdef" From c88ff82efe837b4ef78bed22f80145a5dd83c9bf Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 18 Aug 2026 23:07:08 +0000 Subject: [PATCH 15/15] refactor(coderd/tracing): add sessionIDFromQueryString for symmetry Extract a sessionIDFromQueryString helper that mirrors sessionIDFromHeaders: extract the client_session_id from its source, validate it, and return an empty string when absent or malformed. sessionIDFromRequest now composes the two extractors, keeping baggage precedence. Each extractor stays self-validating so sessionIDFromHeaders keeps its validated contract, which the agent SessionIDMiddleware relies on. Adds a symmetric TestSessionIDFromQueryString internal test. --- coderd/tracing/httpmw.go | 20 +++++++++++++++----- coderd/tracing/httpmw_internal_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 2ba3e6a3cb85f..b0a174e0b2ec6 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "net/http" + "net/url" "github.com/go-chi/chi/v5" "go.opentelemetry.io/otel" @@ -94,11 +95,7 @@ func sessionIDFromRequest(r *http.Request) string { if id := sessionIDFromHeaders(r.Header); id != "" { return id } - id := r.URL.Query().Get(SessionIDBaggageKey) - if !validSessionID(id) { - return "" - } - return id + return sessionIDFromQueryString(r.URL.Query()) } // sessionIDFromHeaders extracts and validates the client_session_id baggage member @@ -114,6 +111,19 @@ func sessionIDFromHeaders(h http.Header) string { return id } +// sessionIDFromQueryString extracts and validates the client_session_id query +// parameter. It returns an empty string when the parameter is absent or +// malformed. It mirrors sessionIDFromHeaders for the query-parameter fallback +// used by browser WebSocket clients (such as the web terminal PTY) that cannot +// set arbitrary baggage headers. +func sessionIDFromQueryString(q url.Values) string { + id := q.Get(SessionIDBaggageKey) + if !validSessionID(id) { + return "" + } + return id +} + // validSessionID reports whether s is a 32-character lowercase hexadecimal // string (a 16-byte value), the encoding the RFC mandates for the session ID. // Only lowercase is accepted so that case-sensitive searches correlate diff --git a/coderd/tracing/httpmw_internal_test.go b/coderd/tracing/httpmw_internal_test.go index 8ced932f44f2b..1fe398430427f 100644 --- a/coderd/tracing/httpmw_internal_test.go +++ b/coderd/tracing/httpmw_internal_test.go @@ -2,6 +2,7 @@ package tracing import ( "net/http" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -62,3 +63,28 @@ func TestSessionIDFromHeaders(t *testing.T) { }) } } + +func TestSessionIDFromQueryString(t *testing.T) { + t.Parallel() + + const validID = "0123456789abcdef0123456789abcdef" + + cases := []struct { + name string + query url.Values + want string + }{ + {"Valid", url.Values{SessionIDBaggageKey: {validID}}, validID}, + {"Missing", url.Values{"foo": {"bar"}}, ""}, + {"Empty", url.Values{}, ""}, + {"Malformed", url.Values{SessionIDBaggageKey: {"not-a-hex-value"}}, ""}, + {"Uppercase", url.Values{SessionIDBaggageKey: {"0123456789ABCDEF0123456789ABCDEF"}}, ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.want, sessionIDFromQueryString(c.query)) + }) + } +}