Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fdc826a
feat(coderd/tracing): correlate request logs and spans by session_id
aqandrew Jul 30, 2026
a929df6
refactor(coderd/tracing): use SessionIDBaggageKey const for the bagga…
aqandrew Jul 30, 2026
7437deb
docs: remove slog field name waffle
aqandrew Aug 6, 2026
54d8d4e
fix(coderd/tracing): only accept lowercase hex session IDs
aqandrew Aug 11, 2026
bfe48cf
test(coderd/tracing): couple session_id field names to the baggage key
aqandrew Aug 11, 2026
79968e2
test(coderd/tracing): assert session_id is skipped on non-matching ro…
aqandrew Aug 11, 2026
2397161
test(coderd/tracing): assert absence of any session_id span attribute
aqandrew Aug 11, 2026
6d28048
refactor(coderd/tracing): validate session IDs with encoding/hex
aqandrew Aug 11, 2026
948b1db
test(coderd/tracing): consolidate fake tracer and cover empty session ID
aqandrew Aug 11, 2026
3bac44f
feat: rename session_id to client_session_id
aqandrew Aug 13, 2026
fdfa486
test(coderd/tracing): rename MalformedBaggage to MalformedSessionID
aqandrew Aug 17, 2026
d48cc49
refactor(coderd/tracing): always span via a no-op tracer
aqandrew Aug 17, 2026
6f262e8
feat(coderd/tracing): fall back to client_session_id query param
aqandrew Aug 17, 2026
c349e92
test(coderd/tracing): drop unnecessary mutex from recordingSpan
aqandrew Aug 18, 2026
27a5a34
Merge remote-tracking branch 'origin/main' into devex-659-session-id-…
aqandrew Aug 18, 2026
c88ff82
refactor(coderd/tracing): add sessionIDFromQueryString for symmetry
aqandrew Aug 18, 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
85 changes: 81 additions & 4 deletions coderd/tracing/httpmw.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@ package tracing

import (
"context"
"encoding/hex"
"fmt"
"net/http"
"net/url"

"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"
"go.opentelemetry.io/otel/trace/noop"

"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 = "client_session_id"

Comment thread
aqandrew marked this conversation as resolved.
// 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
Expand All @@ -29,18 +40,26 @@ 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) {
if tracer == nil || !re.MatchString(r.URL.Path) {
if !re.MatchString(r.URL.Path) {
Comment thread
aqandrew marked this conversation as resolved.
next.ServeHTTP(rw, r)
return
}

// 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 := sessionIDFromRequest(r)
if sessionID != "" {
Comment thread
aqandrew marked this conversation as resolved.
r = r.WithContext(slog.With(r.Context(), slog.F("client_session_id", sessionID)))
}
Comment thread
aqandrew marked this conversation as resolved.

// Start span with default span name. Span name will be updated to
// "method route" format once request finishes. The initial name
// excludes the query string because span names are exported to
Expand All @@ -49,6 +68,10 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
defer span.End()

if sessionID != "" {
span.SetAttributes(attribute.String("client_session_id", sessionID))
}

sw, ok := rw.(*StatusWriter)
if !ok {
panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw))
Expand All @@ -62,6 +85,60 @@ 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 != "" {
Comment thread
aqandrew marked this conversation as resolved.
return id
}
return sessionIDFromQueryString(r.URL.Query())
}

// 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.
func sessionIDFromHeaders(h http.Header) string {
ctx := propagation.Baggage{}.Extract(context.Background(), propagation.HeaderCarrier(h))
Comment thread
jeremyruppel marked this conversation as resolved.
id := baggage.FromContext(ctx).Member(SessionIDBaggageKey).Value()
if !validSessionID(id) {
return ""
}
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
// reliably. Validating also guards against logging arbitrary client-controlled
// baggage values.
func validSessionID(s string) bool {
b, err := hex.DecodeString(s)
if err != nil || len(b) != 16 {
return false
}
// 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
// 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) {
Expand Down
90 changes: 90 additions & 0 deletions coderd/tracing/httpmw_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package tracing

import (
"net/http"
"net/url"
"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", false},
{"MixedCase", "0123456789abcdef0123456789ABCDEF", false},
{"Empty", "", false},
{"TooShort", "0123456789abcdef0123456789abcde", false},
{"TooLong", "0123456789abcdef0123456789abcdef0", false},
{"NonHex", "0123456789abcdef0123456789abcdeg", false},
{"Uuid", "0123456789ab-cdef-0123456789abcde", false},
Comment thread
aqandrew marked this conversation as resolved.
}

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", SessionIDBaggageKey + "=" + validID, validID},
{"WithOtherMembers", "foo=bar," + SessionIDBaggageKey + "=" + validID + ",baz=qux", validID},
{"Missing", "foo=bar", ""},
{"NoHeader", "", ""},
{"Malformed", SessionIDBaggageKey + "=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))
})
}
}

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))
})
}
}
Loading
Loading