feat: buffer connection logs and flush them on failure - #1100
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.
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.
The support-bundle flush change made Commands call serviceContainer.getConnectionLogBuffer(), but the telemetry, netcheck, and updateWorkspace Commands mocks did not provide it, so their suites threw "getConnectionLogBuffer is not a function" in CI.
The strict `as ServiceContainer` cast requires each property to match exactly, so annotate the getter's return as ConnectionLogBuffer to restore comparability and fix the TS2352 typecheck error.
… CLI
A support bundle is a snapshot, so a missed entry should be recoverable by
the next bundle or failure: flush("support_bundle") now retains the ring
instead of clearing it. Move the flush above cliExec.supportBundle so the
CLI's runtime gives LogOutputChannel time to write the lines to disk.
Capture is still best-effort; the channel writes on its own schedule.
- Flush header reads "replaying N buffered entries (reason)"; the old "connection failure (reason)" was wrong for a support_bundle or a closeRemote() cancel. - Relabel all four closeRemote() callers remote_closed; two of them are cancels, not workspace_open_failed. - CONTRIBUTING: only the first physical line of an entry carries the timestamp and level; continuation lines carry the bare marker. Describe bundle capture as best-effort. - package.json: describe bundle capture as best-effort. - Drop the replaySink comment's "whatever the user's log level" claim; flush returns early at Off. - Add an Unreleased changelog entry for the setting and normal-close reconnect.
Reconnecting after server-initiated normal closes made normal_close unemittable, but it stayed in ConnectionStateReason and ConnectionDropCause and in the EVENTS.md tables, so anyone filtering on it got a silent zero rather than a signal the behavior changed. Remove it from both unions and both tables, and give the double-emit test an emittable reason.
addEventListener and removeEventListener each spent a five-case switch on non-message events that all do the same cast and call. The per-event overloads force the cast either way, so one cast covers open/close/error.
handshakeStatus parses two libraries' internal error text, and neither is a contract; the existing tests hand-build the strings, so a reword on a bump would pass here and break in production. Drive a real ws client against a server answering 404 on the upgrade and a real EventSource against one answering 403, so a reword fails in CI instead. Reading the status from the libraries' public event APIs is tracked in #1118.
The container was otherwise pure getters; the onConnectionFailure funnel is behavior. Move the `<reason> <route>` arrow onto BufferingLogger and add it to ConnectionLogBuffer, so the grep format sits next to the code that writes the line. Call sites read getConnectionLogBuffer().onConnectionFailure. Default the socket's onConnectionFailure option to a noop, so it lives in #options and drops the #onConnectionFailure field, the optional-call at the terminated funnel, and one member of the Required<Omit<...>>.
All three watch callers passed the route twice, and two duplicated a template literal, so the createReconnectingSocket argument and the socket init could drift. Hoist a single apiRoute per caller and pass it by shorthand into the init. Restore getLogLevel's explicit HttpClientLogLevel return type, and expand the #lastRoute comment: it is not simply the seeded route, since the live URL diverges after an SSE fallback and after a followRedirects redirect.
EhabY
left a comment
There was a problem hiding this comment.
Code is solid: every thread from the last round checks out, and typecheck, lint, and the unit suites are clean. Approving, the three suggestions are wording only.
Description: Flush reasons (D4) still lists normal_close, which this PR deletes. The Off bullet still promises the context "survives until logging is turned back on", the phrasing we dropped from flush(), since nothing watches onDidChangeLogLevel.
…correlated unions Co-authored-by: Ehab Younes <ehab.alyounes@gmail.com>
Co-authored-by: Ehab Younes <ehab.alyounes@gmail.com>
…opens Co-authored-by: Ehab Younes <ehab.alyounes@gmail.com>
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 at the least-verbose level the channel still persists. The first physical line of each entry carries a[buffered]marker with its original ISO timestamp and level; continuation lines carry the bare marker. Capture is best-effort — the channel writes on its own schedule — but a later failure flush replays anything a bundle missed.flush("support_bundle", { retain: true })) before the CLI runs, and retains the ring so a later failure flush still has the entries.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().BufferingLogger.onConnectionFailure(reason, route)(part ofConnectionLogBuffer), so the extension and remote paths flush${reason} ${route}identically; the socket option defaults to a noop when no observer is supplied.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, so the now-unreachablenormal_closereason is dropped from the telemetry unions andEVENTS.md.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,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.