feat: correlate web terminal sessions by client_session_id - #27677
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.
Generate a 16-byte session ID (32-char hex) per web terminal session and attach it to the terminal's requests and client logs, per the connection-log correlation RFC. This is DEVEX-663 and stacks on the coderd tracing middleware change (DEVEX-659). Frontend: - Add generateSessionId and mint one session ID per web terminal page load (TerminalPage) and per AgentsPage terminal panel mount. Unlike the reconnection token, it is not persisted in the URL: a reload is a new session. - Send it on the PTY WebSocket as a session_id query parameter (browsers cannot set the baggage header on a WebSocket handshake). - Send it via W3C baggage on the reconnecting-pty signed-token API request. - Include it in the terminal's connection-error console logs. Backend: - The reconnecting-pty WebSocket handler reads the session_id query parameter, validates it, and attaches it to the request and PTY logs so the WebSocket session correlates. Export tracing.ValidSessionID for reuse.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Co-authored-by: Danielle Maywood <[email protected]>
Co-authored-by: Danielle Maywood <[email protected]>
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.
- adds a `generateConnectionSessionId` helper function which reuses our existing `generateRandomString` logic - adds tests for `generateConnectionSessionId` in utils/random.test.ts (utils/random.ts was previously untested) - renames our existing `generateRandomString` function to be `generateRandomBase64String` additional context in #27671 (comment) This PR doesn't change any of our UIs. The new `generateConnectionSessionId` function is a piece of frontend plumbing related to DEVEX-663. I originally implemented #27677 so that web terminal connections to workspaces would be identified by uuids--but according to the RFC for DEVEX-663, those connection session ids should be lowercase hexadecimal strings (not uuids).
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).
…utes 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).
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).
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).
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.
…dleware' into devex-663-web-terminal-session-id # Conflicts: # coderd/tracing/httpmw.go
…-web-terminal-session-id
The baggage is well-formed; it is the session ID value that is malformed. Addresses review feedback on #27671.
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.
…-web-terminal-session-id
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.
…-web-terminal-session-id
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.
…-web-terminal-session-id
…tracing-middleware # Conflicts: # coderd/tracing/httpmw_test.go
…-web-terminal-session-id
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.
…-web-terminal-session-id
…l-session-id # Conflicts: # site/src/pages/TerminalPage/TerminalPage.test.tsx # site/src/pages/TerminalPage/TerminalPage.tsx
| const { proxy } = useProxy(); | ||
| const { metadata } = useEmbeddedMetadata(); | ||
| const terminalRef = useRef<WorkspaceTerminalHandle>(null); | ||
| const [sessionId] = useState(() => generateConnectionSessionId()); |
There was a problem hiding this comment.
I tried testing this panel but honestly I am not sure how to get one opened. Maybe I need an agent that has a workspace associated or something?
Do you know if this session ID would get regenerated if you close and re-opened the tab or anything like that? Not entirely sure how it would work with React, I think it might unmount it then we would reconnect and also have a new session ID even if the page has not been reloaded.
There was a problem hiding this comment.
Maybe I need an agent that has a workspace associated or something?
Yep that's correct:
coder/site/src/pages/AgentsPage/AgentChatPageView.tsx
Lines 260 to 261 in 903d7b7
It's in the panel on the right, in the Terminal tab:
There was a problem hiding this comment.
Do you know if this session ID would get regenerated if you close and re-opened the tab or anything like that? ... I think it might unmount it then we would reconnect and also have a new session ID even if the page has not been reloaded.
That's also correct, a new session ID would be generated whenever TerminalPanel remounts.
So in the situation of closing+reopening the terminal tab, reconnectionToken would persist. However, currently client_session_id does not persist since a new one would be generated. This is not
explicitly terminating the workspace connection
as described in the RFC. Do you think we should consider it the same session (i.e., persist the session ID) since we're reattaching to a live PTY? Then I think we'd have to derive/persist the session ID with reconnectionToken, instead of storing it in React state in TerminalPanel
There was a problem hiding this comment.
Yeah I think it should probably be the same session ID as long as we remain on that same page. The umount/destroy is more an implementation detail, from the user's perspective I would say it is still the same session, and it is more like just temporarily hiding the terminal than disconnecting.
There was a problem hiding this comment.
Agreed, and that's how I implemented it. The client session ID now stays the same as long as you're on the page (no reload). I moved it off TerminalPanel's local useState into a module-level registry keyed by reconnectionToken, so a remount (switching chats, or hiding/showing the terminal) reuses the same ID and only a reload starts a new one.
One nuance worth calling out: "close/reopen" behaves differently depending on the tab, because of what handleCloseTab does:
| Action | reconnectionToken |
client_session_id |
|---|---|---|
| Built-in terminal close/reopen | persists (it's the chat id; the tab is only hidden, not removed) | same (reattaching to the live PTY) |
| User terminal tab close/reopen | new (the tab is removed, so reopening mints a new token) | new (a brand-new PTY) |
So closing the built-in Terminal tab keeps the session (matches your "temporarily hiding" framing), while closing a user-created terminal tab and opening a new one is genuinely a new session.
Done in 86801ed: a reconnectionToken -> client_session_id registry that survives remounts and is cleared on reload.
This reply was generated by Coder Agents.
There was a problem hiding this comment.
Ah cool yeah that makes sense, closing the terminal tab is like an explicit disconnect, so new ID. That makes sense to me.
There was a problem hiding this comment.
Following up on our Slack conversation for posterity, since it isn't captured in this PR: you pointed out that keying the client session ID off reconnectionToken couples it to the PTY identity. If someone later changes how the reconnection token is derived (so it's no longer the chat/agent id), that would quietly regenerate the session ID mid-session even without a page reload. You suggested generating the session ID at the page component instead, so it's tied to page load / navigating to and from the page, regardless of what the reconnection token does.
Agreed, that's a cleaner separation of concerns. I'm moving generation up to AgentChatPageView (keyed by agentId) and providing it to every TerminalPanel via context, and dropping the reconnectionToken-keyed registry. reconnectionToken goes back to meaning only "which PTY to reattach to," and the session ID means "this client's visit to the page."
Resulting behavior:
| Event | client_session_id |
|---|---|
| Switch tabs / terminal remount / websocket reconnect | same |
| Built-in or user terminal close then reopen | same |
Switch to a different chat (agentId changes) |
new |
| Navigate away and back, or reload | new |
Note this supersedes the earlier table I posted: now both the built-in terminal and a user-created terminal keep the same ID across close/reopen, because it's one session per page visit rather than per PTY.
This reply was generated by Coder Agents.
…cross remounts TerminalPanel generated the client_session_id with a local useState, so it regenerated on every remount (switching chats, or hiding/showing the built-in terminal tab) even though the terminal reattaches to the same live PTY. Per PR review, the id should stay the same while the user remains on the page and only reset on reload. Move ownership into a module-level registry keyed by reconnectionToken. It survives in-page remounts and resets on reload. The built-in terminal keeps its token (the chat id) across hide/show, so it keeps its session id; a user-created terminal tab is removed on close and mints a new token on reopen, so it correctly gets a new session id. Generated by Coder Agents.
… to the page The client_session_id was keyed by reconnectionToken in a module-level registry. That coupled the session identity to the PTY reconnection token, so a future change to how the token is derived could silently regenerate the session ID mid-session without a page reload. Generate the ID once in AgentChatPageView (keyed by agentId) and share it with every terminal through TerminalClientSessionContext. reconnectionToken now only identifies which PTY to reattach to. The session ID stays stable across tab switches, terminal remounts, and terminal close/reopen, and regenerates when switching chats or reloading. Drop the reconnectionToken-keyed registry. Generated by Coder Agents.
…l-session-id # Conflicts: # site/src/pages/AgentsPage/AgentChatPageView.tsx

What
Implements the web terminal client half of the
Connection log collection and correlation RFC
(
DEVEX-663). Generates a per-session correlation ID and attaches it to theweb terminal's requests and client logs so a single session can be traced end
to end.
Changes
Session ID
Generated with the
generateConnectionSessionIdfunction added in chore(site): add generateConnectionSessionId helper function #27935TerminalPageload and onAgentsPageterminal panel mount. Unlike the reconnection token, it is notpersisted in the URL, so a reload (or a new tab) is a new session, matching the
RFC's session definition.
Propagation
via W3C baggage (
baggage: client_session_id=<hex>), which the DEVEX-659 middlewarereads.
baggageheader on a WebSockethandshake (the codebase already works around this for the session token), so
the ID is sent as a
client_session_idquery parameter instead. The reconnecting-ptyWebSocket handler reads and validates it and attaches it to the request and PTY
logs.
Client logs
console.errorlogs now includeclient_session_id.Telemetry: the web terminal emits none today, so there is nothing to tag
(confirmed with the issue reporter).
Testing
site: unit tests forgenerateSessionId(format + uniqueness) andterminalWebsocketUrl(query param). UpdatedTerminalPage.test.tsx(mocks thegenerator to a fixed ID and asserts the WebSocket URL includes
client_session_id).tsc, Biome, and the React Compiler check pass.coderd:go test ./coderd/tracing/...andgo vet ./coderd/workspaceapps/...pass; new
ValidSessionIDexport reused by the PTY handler.Design notes / decision log
reconnecttoken isdeliberately persisted in the URL to survive reloads.
client_session_idis theopposite: a fresh value per page load, matching the RFC (a reload is a new
session). They are separate identifiers.
instance is a singleton shared by the whole app; a global
baggagedefaultwould tag unrelated requests. The header is attached only to the terminal's
signed-token request.
WebSocketcannot send customheaders, so baggage is impossible on the PTY handshake. The
client_session_idqueryparameter is the counterpart, read server-side in
workspaceAgentPTY.terminal's own WebSocket handler rather than broadening the shared tracing
middleware to trust query params on every route.
32-char hex string (
tracing.ValidSessionID) before logging, to avoid loggingarbitrary client-controlled input.
AgentsPageis React Compiler optimized(no
useMemo/useCallback), so the panel mints its ID withuseStatelazyinit instead.
connection_logscolumns, Tailnet state-change logging, and the CLI
CODER_TRACE_SESSION_IDenv var.
Opened by Coder Agents on behalf of @aqandrew.