Skip to content

feat(coderd/tracing): correlate request logs and spans by session_id - #27671

Open
aqandrew wants to merge 3 commits into
mainfrom
devex-659-session-id-tracing-middleware
Open

feat(coderd/tracing): correlate request logs and spans by session_id#27671
aqandrew wants to merge 3 commits into
mainfrom
devex-659-session-id-tracing-middleware

Conversation

@aqandrew

Copy link
Copy Markdown
Contributor

What

Adds session_id correlation to coderd's HTTP request handling, per the
Connection log collection and correlation RFC.

Clients attach a per-session correlation ID to every API request via W3C
baggage using the session_id key. This change makes coderd's tracing
middleware read that baggage member and:

  • add session_id to the per-request log context so all logs for a
    request (and the handlers it calls) can be correlated by a single ID, and
  • set session_id as a span attribute when tracing is enabled.

Per RFC requirement 6.1, the value is added to the log context even when
tracing is disabled
(the middleware previously returned early when no tracer
provider was configured, so baggage was never read). The session_id is
validated as a 32-character hexadecimal string (a 16-byte value, per RFC
requirement 1) to guard against logging arbitrary client-controlled baggage
values.

Scope

This is DEVEX-659 and is intentionally limited to the coderd tracing
middleware. It is the first piece of a stack: the web terminal client change
(DEVEX-663) that generates and sends the session_id will be stacked on top
of this PR. No client currently sends session_id baggage, so this change is
a no-op until the client work lands.

Testing

  • coderd/tracing: new unit tests cover validSessionID, baggage extraction
    (sessionIDFromHeaders), and the middleware end to end, asserting
    session_id lands on the log context with tracing enabled and disabled,
    is exposed as a span attribute when tracing is enabled, and that
    absent/malformed baggage is ignored.
  • Existing Test_Middleware route-matching behavior is unchanged.
Design notes / decision log
  • Where the value is read: the existing tracing.Middleware runs high in
    the coderd middleware stack (coderd/coderd.go), before request-id and
    request-logger middleware, and already matches the /api, /api/**, app
    proxy, and external-auth routes. Reading baggage here means the session_id
    is on the context before the request logger and handlers run, so it flows
    into all downstream slog calls that use the request context. This mirrors
    the existing request_id pattern in httpmw.AttachRequestID
    (slog.With(ctx, ...) + span attribute).
  • Works when tracing is off: the middleware now gates only on the route
    matcher, extracts baggage and adds session_id to the log context for all
    matched routes, and only then branches on whether a tracer is configured.
    When a tracer is present, session_id is additionally set as a span
    attribute.
  • Explicit baggage propagator: extraction uses propagation.Baggage{}
    directly rather than the global text map propagator, so it does not depend
    on the global propagator being configured (also makes it deterministic in
    tests).
  • Validation: only a 32-char hex string is accepted (lower or upper case).
    Malformed values are dropped rather than logged, preventing log/attribute
    pollution from arbitrary client-supplied baggage.
  • Out of scope for this PR (tracked elsewhere): client generation/sending
    of session_id (DEVEX-663, web terminal), the equivalent agent-side
    middleware (RFC 6.2), connection_logs.session_id (RFC 12), and additional
    connection state-change logging (RFC 7-13).

Opened by Coder Agents on behalf of @aqandrew.

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.
@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

DEVEX-659

…ge 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.

Copy link
Copy Markdown
Contributor Author

{"TooShort", "0123456789abcdef0123456789abcde", false},
{"TooLong", "0123456789abcdef0123456789abcdef0", false},
{"NonHex", "0123456789abcdef0123456789abcdeg", false},
{"Uuid", "0123456789ab-cdef-0123456789abcde", false},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jeremyruppel I just re-read the RFC; initial functional requirement 1 is:

Clients must generate 16-byte session ID encoded as a 32-character hexadecimal string.

UUIDs don't conform--they include hyphens in addition to hexadecimal chars. So httpmw's validSessionID function (added in this PR) will return false for a UUID.

const sessionId = useMemo(() => uuidv4(), []);

I believe we will need a helper function to generate session IDs for the template builder after all, like #27661, just without the hyphens

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

see #27935

@jeremyruppel jeremyruppel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, tightly-scoped change. The strict 32-char hex validation at the trust boundary and the explicit (non-global) baggage propagator are both the right calls, and the tests exercise real behavior through a slog sink and a recording span rather than tracing internals.

A few things to look at: 1 P2 and 2 P3 findings, 1 nit, and 4 observations across 9 inline comments. Nothing blocking.

Review generated by Coder Agents (deep-review) on behalf of @jeremyruppel.

Comment thread coderd/tracing/httpmw.go
sessionID := sessionIDFromHeaders(r.Header)
if sessionID != "" {
r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID)))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The session_id correlation key is three independent string literals with nothing keeping them in sync. (Structural Analyst P2, Go Architect P3, Duplication Checker Obs, Security Reviewer Obs, Style Reviewer Nit)

SessionIDBaggageKey = "session_id" is the const (line 25), but the log field (slog.F("session_id", sessionID), line 57) and the span attribute (attribute.String("session_id", sessionID), line 71) are hardcoded string literals. If a future editor renames one literal, correlation silently breaks with no test failure (the tests also hardcode "session_id" in one path).

The whole point of the feature is that the baggage key, log field, and span attribute all read session_id so operators can join logs and traces. Cheapest fix that makes the coupling explicit: a second unexported const (e.g. sessionIDField = "session_id") referenced by both the slog.F and attribute.String calls. Not blocking, but worth doing while it's fresh.

Comment thread coderd/tracing/httpmw.go
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f':
case c >= 'A' && c <= 'F':
default:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 validSessionID accepts upper-case hex, which can fragment correlation. (Edge Case Analyst P3, Contract Auditor P3, Structural Analyst P3, Security Reviewer Obs)

validSessionID treats a-f and A-F as valid, and sessionIDFromHeaders returns the raw member value with no case normalization. If any producer of these IDs emits lowercase (Go's %x, hex.EncodeToString, and trace.TraceID.String() all emit lowercase) while another client sends uppercase, exact-string correlation across logs/spans/other subsystems silently fails to join records that are semantically the same session.

The doc comment says the function enforces "the encoding the RFC mandates," which overstates it if mixed case is accepted. Either normalize with strings.ToLower before returning from sessionIDFromHeaders, or tighten the validator to the RFC's canonical case, and reconcile the comment. TestValidSessionID explicitly asserts UpperHex is valid, so please confirm the intended casing against the RFC.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@code-asher My gut says we should accept lowercase hex only, wdyt?

Comment thread coderd/tracing/httpmw.go
// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit validSessionID hand-rolls a hex check that encoding/hex already covers. (Duplication Checker, Modernization Reviewer)

The repo already validates hex strings with encoding/hex.DecodeString in several places, e.g. coderd/cryptokeys/cache.go, enterprise/coderd/appearance.go (validateHexColor), and codersdk/x/agenthooks/jwt.go. The same result is achievable with b, err := hex.DecodeString(s); return err == nil && len(b) == 16.

Optional and marginal: the current loop is correct and allocation-free, whereas hex.DecodeString allocates a 16-byte buffer per matched request. Note it also accepts upper-case, so it does not resolve the casing concern above. Only worth switching alongside that fix, for consistency with the codebase convention.

Comment thread coderd/tracing/httpmw.go
// 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Obs The session_id field name collides with an existing, differently-typed session_id concept. (Contract Auditor, Structural Analyst, Edge Case Analyst)

session_id is already an established field name with a different format and meaning in other subsystems, e.g. agentapi/boundary_logs.go logs slog.F("session_id", sessionID.String()) where it is a UUID. This PR introduces a third meaning: a 32-char hex "connection-log RFC" correlation ID.

slog.With appends rather than replaces, so if a future handler on the /api/** path logs its own UUID session_id, one log line carries two session_id fields with different semantics. No collision on that path today, but a more specific name (e.g. connection_session_id) would keep the signal honest.

Comment thread coderd/tracing/httpmw.go
// 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Obs session_id is unauthenticated, client-controlled input, logged before auth runs. (Security Reviewer, Contract Auditor)

This middleware sits high in the stack, so the field is attached to the request log context before any authentication/authorization middleware executes. A client can forge or reuse any 32-hex value, so two unrelated actors can deliberately collide on the same session_id to muddy an investigation, and the field appears in logs regardless of auth outcome.

Inherent to a baggage-propagated correlation ID and presumably fine per the RFC. Worth a note in the connection-log RFC / operator docs that session_id in logs is client-asserted, not a trustworthy identity signal. The strict hex validation correctly prevents log/attribute injection from this value.

Comment thread coderd/tracing/httpmw.go
// This is done even when tracing is disabled so that logs can
// always be correlated by session_id.
sessionID := sessionIDFromHeaders(r.Header)
if sessionID != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Obs Baggage is now parsed on the tracing-disabled path, and twice when tracing is enabled. (Performance Analyst, Go Architect)

Before this change, when tracer == nil the middleware returned immediately after the route match with zero extra work. Now every matched request first calls sessionIDFromHeaders(r.Header). When tracer != nil, StartHTTPSpan calls otel.GetTextMapPropagator().Extract(...), which parses the same baggage header again.

Negligible for the common no-baggage-header case (one header-map miss, no allocation) and bounded by otel's W3C limits otherwise, so not a blocker. The duplicate parse is avoidable by reading the member off the context StartHTTPSpan produces, at the cost of coupling to the global propagator, which the code intentionally avoids per the function comment. Flagging so the tradeoff stays conscious.

const testSessionID = "0123456789abcdef0123456789abcdef"

func Test_Middleware_SessionID(t *testing.T) {
t.Parallel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 No test covers the non-matching-route path this change reordered. (Test Auditor)

Middleware now checks re.MatchString before extracting the session_id, so extraction/logging is intentionally skipped on unmatched routes. Every Test_Middleware_SessionID subtest uses /api/v2/workspaces, which always matches. A regression that moved extraction back above the route check (logging session_id on static/asset routes, i.e. leaking client-controlled baggage into logs for every request) would not be caught.

Add a subtest that sends baggage to a non-matching path (e.g. /index.html) and asserts session_id is absent from both the log fields and the span. Cheap insurance against a client-baggage log leak.

_, 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"))
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 The MalformedBaggage span assertion is weaker than intended. (Test Auditor)

the subtest asserts require.NotContains(tp.span.attributes(), attribute.String("session_id", "not-a-valid-session-id")). That only proves the specific malformed string wasn't set. A bug that set session_id to some other derived value would still pass. The real invariant is "no session_id attribute is set at all".

Scan tp.span.attributes() for any attribute whose Key == "session_id" and assert its absence, so the test resists that class of bug.

// recordingTracer is a trace.TracerProvider/Tracer that hands out a single
// recordingSpan.
type recordingTracer struct {
noop.TracerProvider

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Obs recordingTracer largely duplicates the existing fakeTracer, and there's no tracing-enabled + no-baggage case. (Duplication Checker, Test Auditor)

fakeTracer already implements TracerProvider/Tracer returning a span; recordingTracer re-implements the identical Tracer/Start plumbing, differing only in that it returns a recordingSpan.

Test-only scaffolding, so low cost; the existing fake could have been extended to optionally hand out a recording span. Separately, a recording-tracer + no-baggage subtest would directly pin the "don't set an empty span attribute when sessionID is empty" branch that the other subtests only cover indirectly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants