Files
coder/coderd/tracing/httpmw_internal_test.go
T
Andrew Aquino 71e95a3611 feat(coderd/tracing): correlate request logs and spans by client_session_id (#27671)
## What

Adds `client_session_id` correlation to coderd's HTTP request handling,
per the
[Connection log collection and correlation
RFC](https://www.notion.so/coderhq/Connection-log-collection-and-correlation-36ed579be5928025a56cd11fe58661fb).

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

- add `client_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 `client_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
`client_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 `client_session_id` will be
stacked on top
of this PR. No client currently sends `client_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
`client_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.

<details>
<summary>Design notes / decision log</summary>

- **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
`client_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 `client_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, `client_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 `client_session_id` (`DEVEX-663`, web terminal), the equivalent
agent-side
middleware (RFC 6.2), `connection_logs.client_session_id` (RFC 12), and
additional
  connection state-change logging (RFC 7-13).

</details>

---

_Opened by Coder Agents on behalf of @aqandrew._
2026-08-18 17:28:36 -07:00

91 lines
2.1 KiB
Go

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