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.
| deploymentSessionAuth?.token, | ||
| output, | ||
| telemetryService, | ||
| (reason) => serviceContainer.getConnectionLogBuffer().flush(reason), |
There was a problem hiding this comment.
This only wires the shared client. Please pass the callback to workspaceClient in remote.ts too, since that client owns the remote workspace’s streams. Add a test verifying that its terminal socket failures flush the buffer. HTTP-only clients can still omit the callback.
| "Got empty error while monitoring workspace", | ||
| ); | ||
| this.logger.error(message); | ||
| this.connectionLogBuffer.flush("workspace_monitor_error"); |
There was a problem hiding this comment.
notifyError() is called when a workspace message cannot be parsed or its processing throws. Neither means the connection has failed; the socket can remain connected and deliver the next message.
Please keep the error log but remove this flush and the monitor’s buffer dependency. Terminal socket failures should flush through onConnectionFailure, once it is wired into the remote client.
| it("flushes the connection log buffer when the socket errors", async () => { | ||
| const { stream, connectionLogBuffer } = await setup(); | ||
|
|
||
| stream.pushError(new Error("socket boom")); |
There was a problem hiding this comment.
pushError() emits a message with parseError, not a socket error. Please change this test to verify that malformed messages do not flush. Test the positive case through the real terminal-failure callback, so we cover the wiring rather than just a mock’s flush() call.
| if (now - this.lastFlushMs < this.flushSuppressionMs) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
If failure A flushes, then new logs arrive before failure B one second later, B’s logs remain buffered with no later write scheduled.
Please remove the suppression window and its state. Clearing the buffer already prevents duplicate replay. Add a test showing that two consecutive failures each flush their newly accumulated entries.
| this.currentLevel = levelSource.getLogLevel(); | ||
| this.levelSubscription = levelSource.onDidChangeLogLevel((level) => { | ||
| this.currentLevel = level; | ||
| }); |
There was a problem hiding this comment.
Can we accept a single getLogLevel: () => number callback and call it from both record() and replayEmitter()? That removes LogLevelSource, the cached level, the change subscription, and its cleanup, while keeping the logger independent of VS Code.
| /** | ||
| * Terminal-failure reasons: the socket has given up and surfaced an error, | ||
| * rather than dropping transiently and auto-reconnecting. These are the moments | ||
| * worth flushing the connection log buffer. | ||
| */ | ||
| const CONNECTION_FAILURE_REASONS: ReadonlySet<ConnectionStateReason> = new Set([ | ||
| "unrecoverable_close", | ||
| "unrecoverable_http", | ||
| "certificate_error", | ||
| ]); | ||
|
|
||
| /** Whether a state-transition reason represents a genuine connection failure. */ | ||
| export function isConnectionFailure(reason: ConnectionStateReason): boolean { |
There was a problem hiding this comment.
Please rename these to TERMINAL_CONNECTION_FAILURE_REASONS and isTerminalConnectionFailure. These are connection failures that stop automatic retries, not all connection errors. The comment can simply say: “Connection failures that stop automatic retries.”
|
|
||
| type Level = keyof typeof SEVERITY; | ||
|
|
||
| const LEVEL_LABEL: Record<Level, string> = { |
There was a problem hiding this comment.
Please use Readonly<Record<Level, string>> here so the labels cannot be reassigned. SEVERITY is already readonly through as const.
| const CONNECTION_LOG_BUFFER_SIZE_KEY = "coder.connectionLogBuffer.size"; | ||
| const DEFAULT_CONNECTION_LOG_BUFFER_SIZE = 1000; |
There was a problem hiding this comment.
Please move these constants below the imports, before the class.
| flush(reason: string): void; | ||
| } | ||
|
|
||
| interface BufferedEntry { |
There was a problem hiding this comment.
Could we call this LogEntry? Its fields describe a log entry; none are specific to buffering.
| /** | ||
| * Wraps a {@link Logger} and keeps a bounded, in-memory ring of entries whose | ||
| * level is **below the sink's current level** — the ones the sink would | ||
| * otherwise drop. On a connection failure, {@link flush} replays those entries | ||
| * into the sink so they persist to disk (and any support bundle), giving Support | ||
| * the debug detail leading up to the failure without the user having enabled | ||
| * debug logging beforehand. | ||
| * | ||
| * Only below-level entries are buffered, so nothing that the sink already writes | ||
| * is ever duplicated. Replay is emitted at the least-verbose level the sink | ||
| * still writes, so the flush lands regardless of the configured level. |
There was a problem hiding this comment.
Please shorten this to the behavior that matters: “Buffers entries below the current log level and replays them on failure at a level the output channel persists.” The implementation explains the rest. Please also avoid em-dashes in the added comments.
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 log lines that sit below the channel's current level — the ones it would otherwise drop. Only below-level lines are buffered, so nothing already written is duplicated.flush(reason)re-emits the captured lines into the output channel (each marked[buffered]with its original ISO timestamp and level) at the least-verbose level the channel still persists, so they land on disk and in support bundles.ServiceContainerand adds thecoder.connectionLogBuffer.sizesetting (default1000,0disables).unrecoverable_close,unrecoverable_http,certificate_error);WorkspaceMonitorsocket error;disconnectedduring connection.CONTRIBUTING.md.Scope notes
ProxyCommandwrites its own file logs undercoder.proxyLogDirectory, which support bundles already collect from disk, so those are not buffered here.Commits
BufferingLogger+ unit testsTesting
pnpm typecheck,pnpm format:check, andpnpm lintare clean.logBuffer,reconnectingWebSocket,workspaceMonitor,workspaceStateMachine,coderApi, plus the container-mock consumers).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, so no separate sink is needed.WorkspaceMonitor.notifyError, and agentdisconnected. Never on transientretryingdrops or intentional teardown (manual_disconnect,normal_close,replaced, dispose/deactivate/reload). A singleisConnectionFailure(reason)predicate gates the socket sites, and a short suppression window coalesces bursts.Logger; do not buffer CLIProxyCommandfile logs already handled viacoder.proxyLogDirectory.Decisions
[buffered]marker/original level/timestamp.Logger; not CLI ProxyCommand file logs.🤖 Generated with Coder Agents. Reviewed and authored on behalf of @aqandrew.