feat(coderd/tracing): correlate request logs and spans by session_id - #27671
feat(coderd/tracing): correlate request logs and spans by session_id#27671aqandrew wants to merge 3 commits into
Conversation
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.
…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.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
| {"TooShort", "0123456789abcdef0123456789abcde", false}, | ||
| {"TooLong", "0123456789abcdef0123456789abcdef0", false}, | ||
| {"NonHex", "0123456789abcdef0123456789abcdeg", false}, | ||
| {"Uuid", "0123456789ab-cdef-0123456789abcde", false}, |
There was a problem hiding this comment.
@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.
I believe we will need a helper function to generate session IDs for the template builder after all, like #27661, just without the hyphens
jeremyruppel
left a comment
There was a problem hiding this comment.
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.
| sessionID := sessionIDFromHeaders(r.Header) | ||
| if sessionID != "" { | ||
| r = r.WithContext(slog.With(r.Context(), slog.F("session_id", sessionID))) | ||
| } |
There was a problem hiding this comment.
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.
| case c >= '0' && c <= '9': | ||
| case c >= 'a' && c <= 'f': | ||
| case c >= 'A' && c <= 'F': | ||
| default: |
There was a problem hiding this comment.
P3 validSessionID accepts upper-case hex, which can fragment correlation. (Edge Case Analyst P3, Contract Auditor P3, Structural Analyst P3, Security Reviewer Obs)
validSessionIDtreatsa-fandA-Fas valid, andsessionIDFromHeadersreturns the raw member value with no case normalization. If any producer of these IDs emits lowercase (Go's%x,hex.EncodeToString, andtrace.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.
There was a problem hiding this comment.
@code-asher My gut says we should accept lowercase hex only, wdyt?
| // 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 { |
There was a problem hiding this comment.
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.DecodeStringin several places, e.g.coderd/cryptokeys/cache.go,enterprise/coderd/appearance.go(validateHexColor), andcodersdk/x/agenthooks/jwt.go. The same result is achievable withb, 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.
| // 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" | ||
|
|
There was a problem hiding this comment.
Obs The session_id field name collides with an existing, differently-typed session_id concept. (Contract Auditor, Structural Analyst, Edge Case Analyst)
session_idis already an established field name with a different format and meaning in other subsystems, e.g.agentapi/boundary_logs.gologsslog.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.
| // 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)) |
There was a problem hiding this comment.
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_idto 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.
| // This is done even when tracing is disabled so that logs can | ||
| // always be correlated by session_id. | ||
| sessionID := sessionIDFromHeaders(r.Header) | ||
| if sessionID != "" { |
There was a problem hiding this comment.
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 == nilthe middleware returned immediately after the route match with zero extra work. Now every matched request first callssessionIDFromHeaders(r.Header). Whentracer != nil,StartHTTPSpancallsotel.GetTextMapPropagator().Extract(...), which parses the samebaggageheader 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() |
There was a problem hiding this comment.
P3 No test covers the non-matching-route path this change reordered. (Test Auditor)
Middlewarenow checksre.MatchStringbefore extracting the session_id, so extraction/logging is intentionally skipped on unmatched routes. EveryTest_Middleware_SessionIDsubtest uses/api/v2/workspaces, which always matches. A regression that moved extraction back above the route check (loggingsession_idon 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")) | ||
| }) |
There was a problem hiding this comment.
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 setsession_idto some other derived value would still pass. The real invariant is "nosession_idattribute 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 |
There was a problem hiding this comment.
Obs recordingTracer largely duplicates the existing fakeTracer, and there's no tracing-enabled + no-baggage case. (Duplication Checker, Test Auditor)
fakeTraceralready implementsTracerProvider/Tracerreturning a span;recordingTracerre-implements the identicalTracer/Startplumbing, differing only in that it returns arecordingSpan.
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.

What
Adds
session_idcorrelation to coderd's HTTP request handling, per theConnection log collection and correlation RFC.
Clients attach a per-session correlation ID to every API request via W3C
baggage using the
session_idkey. This change makes coderd's tracingmiddleware read that baggage member and:
session_idto the per-request log context so all logs for arequest (and the handlers it calls) can be correlated by a single ID, and
session_idas 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_idisvalidated 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-659and is intentionally limited to the coderd tracingmiddleware. It is the first piece of a stack: the web terminal client change
(
DEVEX-663) that generates and sends thesession_idwill be stacked on topof this PR. No client currently sends
session_idbaggage, so this change isa no-op until the client work lands.
Testing
coderd/tracing: new unit tests covervalidSessionID, baggage extraction(
sessionIDFromHeaders), and the middleware end to end, assertingsession_idlands 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.
Test_Middlewareroute-matching behavior is unchanged.Design notes / decision log
tracing.Middlewareruns high inthe coderd middleware stack (
coderd/coderd.go), before request-id andrequest-logger middleware, and already matches the
/api,/api/**, appproxy, and external-auth routes. Reading baggage here means the
session_idis on the context before the request logger and handlers run, so it flows
into all downstream
slogcalls that use the request context. This mirrorsthe existing
request_idpattern inhttpmw.AttachRequestID(
slog.With(ctx, ...)+ span attribute).matcher, extracts baggage and adds
session_idto the log context for allmatched routes, and only then branches on whether a tracer is configured.
When a tracer is present,
session_idis additionally set as a spanattribute.
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).
Malformed values are dropped rather than logged, preventing log/attribute
pollution from arbitrary client-supplied baggage.
of
session_id(DEVEX-663, web terminal), the equivalent agent-sidemiddleware (RFC 6.2),
connection_logs.session_id(RFC 12), and additionalconnection state-change logging (RFC 7-13).
Opened by Coder Agents on behalf of @aqandrew.