Conversation
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.
EhabY
left a comment
There was a problem hiding this comment.
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.
…ILURE_REASONS, and isConnectionFailure to isTerminalConnectionFailure
… getConnectionLogBuffer
EhabY
left a comment
There was a problem hiding this comment.
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.
…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
There was a problem hiding this comment.
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.
…terfaces to remove type casts
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.
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
BufferingLoggerdecorator (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 (viasafeStringify) at record time.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.MAX_BUFFERED_CHARS = 2_000_000);trim()enforces both budgets with oldest-eviction, andflush()replays in chunks of 100 to avoid a single oversized write.ServiceContainerand adds thecoder.connectionLogBuffer.sizesetting (default1000, capped at10000,0disables). The setting readers live insrc/settings/logger.tsand the container reads/watches the size through a singlereadSize()helper viawatchConfigurationChanges. The setting is included inCOLLECTED_SETTINGS.failure?: booleanon the connection-log reason (not on transient reconnects, a handshake401, or intentional teardown):unrecoverable_close,unrecoverable_httpwhere the status is not401, orcertificate_error);Remote.closeRemote().ServiceContainer.onConnectionFailure(reason, route)callback, so the extension and remote paths flush${reason} ${route}identically.unknown.handshakeStatus(error)(src/websocket/utils.ts) covering bothws(Unexpected server response: <code>) andeventsource(Non-200 status code (<code>)), so a host/port such as127.0.0.1:4040is no longer misread as HTTP 404 and the SSE path is handled.CoderApi.is404Errorcompares againstHttpStatusCode.NOT_FOUND.registration_access_tokenin HTTP body logging alongside the other sensitive fields.CONTRIBUTING.md.WebSocket event fix
OneWayWebSocketnow registersopen/close/errorvia DOM-styleaddEventListener, socloseconsumers receive a realCloseEventwith.code/.reason. This makesunrecoverable_closereachable in production (message events still usews.on("message", ...)for JSON parsing). Server-initiated normal closes (1000/1001) now go throughscheduleReconnectrather than parking the socket.Scope notes
ProxyCommandfile logs undercoder.proxyLogDirectoryare not, since support bundles already collect them from disk.Testing
pnpm typecheck,pnpm format:check, andpnpm lintare clean.logBuffer,settings/logger,formatters,reconnectingWebSocket,oneWayWebSocket,coderApi,workspaceMonitor,workspaceStateMachine,remote,commands.supportBundle,instrumentation/websocket).Implementation plan & design decisions
Design
[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.failure?: boolean— reconnecting-socket terminal failures (except a401handshake) and a workspace-open failure funnelled throughcloseRemote(). Never on transientretryingdrops or intentional teardown (manual_disconnect,normal_close,replaced, dispose/deactivate/reload). The callback carries the failing route.Logger; do not buffer CLIProxyCommandfile logs already handled viacoder.proxyLogDirectory.Decisions
[buffered]marker/original level/timestamp; retain at Off; also flush on support-bundle collection.401, and not intentional teardown.Logger; not CLI ProxyCommand file logs.🤖 Generated with Coder Agents. Reviewed and authored on behalf of @aqandrew.