Skip to content

feat: buffer connection logs and flush them on failure - #1100

Open
aqandrew wants to merge 49 commits into
mainfrom
aqandrew/devex-669-vs-code-add-log-buffer
Open

aqandrew wants to merge 49 commits into
mainfrom
aqandrew/devex-669-vs-code-add-log-buffer

Conversation

@aqandrew

@aqandrew aqandrew commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Implements RFC requirement 13 / DEVEX-669: buffer connection debug logs in memory below the current log level and flush them on a genuine connection failure, so a support bundle captures the detail leading up to the failure without the user having enabled debug logging beforehand.

What this does

  • Adds a BufferingLogger decorator (src/logging/logBuffer.ts) that wraps the "Coder" output channel and keeps a bounded, in-memory ring of the entries that sit below the channel's current level, which it would otherwise drop. Only below-level entries are buffered, so nothing already written is duplicated. Entries are formatted (via safeStringify) at record time.
  • On a connection failure, flush(reason) re-emits the captured entries into the output channel (each marked [buffered], with its original ISO timestamp and level, and [buffered] re-prefixed on every physical line of multi-line entries) at the least-verbose level the channel still persists, so they land on disk and in support bundles. Collecting a support bundle also flushes the buffer (flush("support_bundle")) immediately before the VS Code logs are appended.
    • When the channel is at Off, flush keeps the entries buffered instead of discarding them, so the context survives until logging is turned back on.
  • The buffer is bounded by both entry count and characters (MAX_BUFFERED_CHARS = 2_000_000); trim() enforces both budgets with oldest-eviction, and flush() replays in chunks of 100 to avoid a single oversized write.
  • Wires the buffer into ServiceContainer and adds the coder.connectionLogBuffer.size setting (default 1000, capped at 10000, 0 disables). The setting readers live in src/settings/logger.ts and the container reads/watches the size through a single readSize() helper via watchConfigurationChanges. The setting is included in COLLECTED_SETTINGS.
  • Flushes only on genuine connection failures, gated by an explicit failure?: boolean on the connection-log reason (not on transient reconnects, a handshake 401, or intentional teardown):
    • a reconnecting WebSocket terminal failure (unrecoverable_close, unrecoverable_http where the status is not 401, or certificate_error);
    • a failure while opening a workspace (canceled build, missing agent, timeout, or CLI/certificate error), funnelled through Remote.closeRemote().
  • Connection-failure flushes are funnelled through a shared ServiceContainer.onConnectionFailure(reason, route) callback, so the extension and remote paths flush ${reason} ${route} identically.
  • The flush reason carries the failing route for attribution; the route is seeded on the socket so even a first-connect failure logs a real route instead of unknown.
  • Handshake status is parsed by a shared handshakeStatus(error) (src/websocket/utils.ts) covering both ws (Unexpected server response: <code>) and eventsource (Non-200 status code (<code>)), so a host/port such as 127.0.0.1:4040 is no longer misread as HTTP 404 and the SSE path is handled. CoderApi.is404Error compares against HttpStatusCode.NOT_FOUND.
  • Redacts registration_access_token in HTTP body logging alongside the other sensitive fields.
  • Documents the behavior, config, hard-kill/OOM loss limitation, and SSH log scope in CONTRIBUTING.md.

WebSocket event fix

OneWayWebSocket now registers open/close/error via DOM-style addEventListener, so close consumers receive a real CloseEvent with .code/.reason. This makes unrecoverable_close reachable in production (message events still use ws.on("message", ...) for JSON parsing). Server-initiated normal closes (1000/1001) now go through scheduleReconnect rather than parking the socket.

Scope notes

Testing

  • pnpm typecheck, pnpm format:check, and pnpm lint are clean.
  • Affected/dependent unit suites pass (logBuffer, settings/logger, formatters, reconnectingWebSocket, oneWayWebSocket, coderApi, workspaceMonitor, workspaceStateMachine, remote, commands.supportBundle, instrumentation/websocket).
Implementation plan & design decisions

Design

  • Buffer: bounded by entry count and characters; captures only calls whose severity is below the channel's current level; formats entries at record time; oldest-eviction; live-resizable via config; replays in chunks.
  • Flush target (D3): replay into the existing "Coder" output channel at a level that still persists, with a [buffered] marker plus original level/timestamp, chronologically next to the real failure logs. Support bundles already collect the on-disk VS Code logs and also trigger a flush before appending them, so no separate sink is needed. At Off, entries are retained rather than discarded.
  • Flush reasons (D4): genuine, surfaced connection failures only, gated by an explicit failure?: boolean — reconnecting-socket terminal failures (except a 401 handshake) and a workspace-open failure funnelled through closeRemote(). Never on transient retrying drops or intentional teardown (manual_disconnect, normal_close, replaced, dispose/deactivate/reload). The callback carries the failing route.
  • SSH scope (D5): buffer extension SSH debug passing through the shared Logger; do not buffer CLI ProxyCommand file logs already handled via coder.proxyLogDirectory.

Decisions

  • D1: buffer all below-level session logs.
  • D2: bound by entry count (and a character budget to cap memory).
  • D3: replay into the existing Coder output channel at a persisted level with [buffered] marker/original level/timestamp; retain at Off; also flush on support-bundle collection.
  • D4: flush only on genuine connection failure; not transient, not a 401, and not intentional teardown.
  • D5: buffer extension SSH debug through the shared Logger; not CLI ProxyCommand file logs.

🤖 Generated with Coder Agents. Reviewed and authored on behalf of @aqandrew.

Wraps a Logger and keeps a bounded in-memory ring of entries below the sink's
current level (the ones it would drop). flush() replays them into the sink at a
level guaranteed to be written, so a connection failure can preserve the debug
detail leading up to it without the user having enabled debug logging.

Only below-level entries are buffered (no duplication of what the sink already
writes); flush is coalesced by a short suppression window.
@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

DEVEX-669

@aqandrew
aqandrew marked this pull request as ready for review September 1, 2026 03:30
@aqandrew
aqandrew requested a review from EhabY September 1, 2026 03:30

@EhabY EhabY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please address the inline comments on remote-client failure wiring, the monitor's failure trigger, and flush suppression. The remaining comments cover simplification, test coverage, and naming.

Review generated with Coder Agents on behalf of @EhabY.

Comment thread src/extension.ts Outdated
Comment thread src/workspace/workspaceMonitor.ts Outdated
Comment thread test/unit/workspace/workspaceMonitor.test.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
Comment thread src/websocket/reconnectingWebSocket.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
Comment thread src/core/container.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
@aqandrew
aqandrew requested a review from EhabY September 9, 2026 19:53

@EhabY EhabY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two blocking items: the ws close event shape makes the unrecoverable_close flush unreachable in production, and the monitor test cannot fail. The rest covers trigger scope, where the settings reader lives, and trimming the buffer and its tests.

The PR description also needs a refresh: it still mentions the suppression window, the monitor trigger, isConnectionFailure, and four commits where there are twenty.

Comment thread src/websocket/reconnectingWebSocket.ts Outdated
Comment thread test/unit/workspace/workspaceMonitor.test.ts Outdated
Comment thread src/logging/logBuffer.ts Outdated
Comment thread src/core/container.ts Outdated
Comment thread src/logging/logBuffer.ts
Comment thread test/unit/logging/logBuffer.test.ts
Comment thread package.json Outdated
…ilures

Exclude UNAUTHORIZED handshakes (token refresh reconnects), fix the
unrecoverable-HTTP substring match, move workspace-open failures to the
single extension catch, and pass the socket route into the flush.
…token

- Return early from flush at Off so context survives until logging returns.
- Add registration_access_token to SENSITIVE_BODY_FIELDS.
- Drop LEVEL_LABEL in favor of level.toUpperCase().
- Pick the replay sink by name instead of a closure.
- Prefix every physical line of multi-line entries with [buffered].
- Describe the max capacity as an entry count, not a byte cap.
- logBuffer: add setup() with level/capacity tables and Date.now spy,
  folding 15 tests into 6.
- reconnectingWebSocket: drop the predicate-only Set.has tables,
  un-export isTerminalConnectionFailure, and assert onConnectionFailure
  through the existing close-code, HTTP, and cert-refresh tests.
- coderApi: give createMockWebSocket fireOpen/fireClose/fireError/fireMessage
  and a connectError option, replacing the interim addEventListener->on
  delegation and the hand-rolled close handler.
- Drop the em dash and the stale agent-disconnected flush trigger.
- Reflect the workspace-open failure trigger and the 401 exclusion.
- Shorten the not-buffered note to a single sentence.
…-code-add-log-buffer

# Conflicts:
#	test/unit/remote/workspaceStateMachine.test.ts
@aqandrew
aqandrew requested a review from EhabY September 15, 2026 01:02

@EhabY EhabY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every thread from the last round is addressed, thanks.

Two regressions fall out of the addEventListener fix (server 1000/1001 closes now park the stream for the session, and the SSE error text no longer matches the status regex), and the support bundle never flushes the buffer. Those three, plus the unknown route, the byte bound, the 401 wording, the .toUpperCase() guard, and closeRemote() as the flush funnel, are what I need before approving. The rest are cleanups; fold them into the same push and I'll approve without another round.

Comment thread src/websocket/oneWayWebSocket.ts
Comment thread src/websocket/reconnectingWebSocket.ts Outdated
Comment thread package.json Outdated
Comment thread src/websocket/reconnectingWebSocket.ts
Comment thread src/logging/logBuffer.ts
Comment thread src/settings/logger.ts
Comment thread test/unit/settings/logger.test.ts
Comment thread test/unit/logging/logBuffer.test.ts Outdated
Comment thread test/unit/logging/logBuffer.test.ts Outdated
Comment thread CONTRIBUTING.md Outdated
handleSocketClose only runs for server-initiated closes (intentional
disconnect()/close() dispatch first and return early), so parking the
socket on 1000/1001 left the workspace monitor and inbox dead for the
session after a coderd redeploy (liveness 1001, shutdown 1000). Drop the
NORMAL_CLOSURE_CODES branch and set so those codes fall through to the
backoff retry.
The addEventListener fix routed SSE handshake failures through the same
status check, but eventsource reports 'Non-200 status code (<code>)',
which the ws-only regex missed, so a 401/403/404/410 on the SSE fallback
retried forever without flushing. Add a shared handshakeStatus() helper
that parses both formats and use it for the unrecoverable-HTTP check and
is404Error (dropping the includes('404') false positive).
The terminal-reason set, isTerminalConnectionFailure, and the negated
flushable option all restated what the three failure call sites already
know. Replace them with a single failure?: boolean on disconnectWithReason
(true for unrecoverable close and certificate errors, status-dependent for
unrecoverable HTTP so a 401 stays non-flushing).
#lastRoute stayed "unknown" until the factory resolved, but waitForOpen
turns every handshake failure into a factory rejection, so the flush
header read "unrecoverable_http unknown" exactly when it mattered. Add a
required route to ReconnectingWebSocketOptions, seed #lastRoute from it,
and thread apiRoute through createReconnectingSocket's three callers.
Body-level HTTP logging can pin tens to hundreds of MB in the ring, with
args held as live references, and flush() sent one RPC per entry (up to
10k) inside the close handler. Format each entry into a single string at
record time, hold a 2 MB character budget alongside the entry count, and
replay 100 prefixed lines per channel call.
A bundle taken mid-outage had nothing, because an unreachable server
retries forever without a terminal reason. Flush the buffer just before
appending VS Code logs so the bundle carries the detail leading up to the
failure, and reword the setting to cover both the terminal-failure and
support-bundle triggers.

Flushing after N failed reconnect attempts stays a follow-up: #1112.
.toUpperCase() ran before the fallback, so a null or number in
settings.json threw inside the axios interceptors and rejected every
request. Return BASIC when the configured value is not a string.
…ainer

The extension and remote clients duplicated the same closure, and the
`<reason> <route>` key is what Support greps for. Expose a single
onConnectionFailure arrow property on ServiceContainer and pass it from
both CoderApi.create sites.
The exemption covers every 401, not only an OAuth-refreshable one, so say
so in the code comment, CONTRIBUTING, and the test name: a 401 explains
itself, and with OAuth a refresh reconnects the same socket.
Args are now formatted into each entry's text, so the [buffered] prefix
really does land on every physical line. Drop the over-claim from the test
name and assert on the joined replay text.
readConnectionLogBufferSize always returns a number, so the typeof guard
on the config-change callback was dead. Hoist a readSize() helper, use it
for the initial size and the watcher's getValue, and set the capacity
straight from it on change.
Combine the individual readConnectionLogBufferSize cases and the invalid-
value table into a single it.each<Case> with { name, value, expected }.
createMockWebSocket kept one handler per event and ignored the handler on
removal, so only the last of production's three close listeners survived;
use a Set per event with identity removal and drop the unused fire helpers.
Add a real OneWayWebSocket test that closes from a ws WebSocketServer with
1002 and asserts the callback receives the DOM CloseEvent code and reason.
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