Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
a800f6e
feat(logging): add BufferingLogger for below-level connection logs
aqandrew Aug 26, 2026
69efd2f
feat(logging): wire the connection log buffer into the service container
aqandrew Aug 26, 2026
bbb9d5b
feat(logging): flush the connection log buffer on connection failures
aqandrew Aug 26, 2026
9c3d21b
docs: document the connection log buffer
aqandrew Aug 26, 2026
00b2c58
refactor: make LEVEL_LABEL readonly
aqandrew Sep 9, 2026
65e0f5c
style: move constants before class
aqandrew Sep 9, 2026
e7108f7
refactor: rename BufferedEntry to LogEntry
aqandrew Sep 9, 2026
b647c8b
refactor: rename CONNECTION_FAILURE_REASONS to TERMINAL_CONNECTION_FA…
aqandrew Sep 9, 2026
71ec1b1
docs: shorten BufferingLogger description comment
aqandrew Sep 9, 2026
bd7df61
chore: add coder.connectionLogBuffer.size to COLLECTED_SETTINGS
aqandrew Sep 9, 2026
f309f0f
refactor: return single BufferingLogger field from both getLogger and…
aqandrew Sep 9, 2026
6002706
refactor(logging): remove the flush suppression window
aqandrew Sep 9, 2026
4cba97b
refactor(logging): use Date.now directly instead of an injected clock
aqandrew Sep 9, 2026
6ce51a5
refactor(logging): take a single getLogLevel callback instead of LogL…
aqandrew Sep 9, 2026
a16495e
refactor(container): dispose the log buffer config subscription via a…
aqandrew Sep 9, 2026
2c389f4
refactor(workspace): stop flushing the log buffer from the monitor's …
aqandrew Sep 9, 2026
d6e49dd
feat(remote): flush the log buffer on the workspace client's terminal…
aqandrew Sep 9, 2026
50cb935
test(workspace): assert malformed messages do not flush the log buffer
aqandrew Sep 9, 2026
ce55a5b
feat(logging): cap the connection log buffer size and describe it as …
aqandrew Sep 9, 2026
5ae77bc
docs: sync connection log buffer notes with the flush and suppression…
aqandrew Sep 9, 2026
b8bf5ba
docs: trim/correct buffering logger descriptions
aqandrew Sep 10, 2026
3308ec0
fix(websocket): register non-message events via addEventListener for …
aqandrew Sep 15, 2026
30d9b08
test(workspace): drop the un-failable monitor buffer test and its plu…
aqandrew Sep 15, 2026
8fd7878
feat(logging): refocus buffer flush triggers on genuine connection fa…
aqandrew Sep 15, 2026
1e01b30
refactor(settings): move logger setting readers into src/settings/log…
aqandrew Sep 15, 2026
88195a7
refactor(logging): trim buffer replay and redact registration_access_…
aqandrew Sep 15, 2026
da4fa14
test: consolidate buffer, reconnect, and websocket-mock tests
aqandrew Sep 15, 2026
e1afe04
docs: tighten the connection log buffer notes
aqandrew Sep 15, 2026
c9ee3ae
Merge remote-tracking branch 'origin/main' into aqandrew/devex-669-vs…
aqandrew Sep 15, 2026
c39b8f2
test: narrow LogMethod type, and factor out LevelCase/CapacityCase in…
aqandrew Sep 15, 2026
d365e41
docs: compress buffer triggers + use active over passive voice
aqandrew Sep 15, 2026
df48a90
docs: shorten logger function comments
aqandrew Sep 15, 2026
32eb53f
docs: rm reference to call sites from ConnectionLogBuffer comment
aqandrew Sep 15, 2026
0ac564a
docs: describe getConnectionLogBuffer per se, not its caller
aqandrew Sep 15, 2026
544d143
docs: remove overpromise about turning logging back on from flush com…
aqandrew Sep 15, 2026
5e53988
fix: flush on every closeRemote
aqandrew Sep 15, 2026
3507a67
fix(websocket): reconnect after server-initiated normal closes
aqandrew Sep 15, 2026
f5eb418
fix(websocket): match ws and eventsource handshake status codes
aqandrew Sep 15, 2026
4e43f5b
refactor(websocket): gate the buffer flush on an explicit failure flag
aqandrew Sep 15, 2026
76d23e2
fix(websocket): seed the socket route so first-connect failures log it
aqandrew Sep 15, 2026
456a9dc
fix(logging): bound the buffer by characters and replay in chunks
aqandrew Sep 15, 2026
6017d6b
feat(logging): flush the connection log buffer into support bundles
aqandrew Sep 15, 2026
91a4464
fix(settings): guard the HTTP log-level reader against non-string values
aqandrew Sep 15, 2026
d8c2176
refactor(core): funnel connection-failure flushes through ServiceCont…
aqandrew Sep 15, 2026
b4077b3
docs(websocket): clarify why a handshake 401 never flushes
aqandrew Sep 15, 2026
cfda03d
test(logging): assert the buffer replay on the joined text
aqandrew Sep 15, 2026
11eff87
refactor(core): read the buffer size through one helper
aqandrew Sep 15, 2026
8fb9373
test(settings): fold the buffer-size cases into one table
aqandrew Sep 15, 2026
6bfbb89
test(websocket): fix the mock listener model and cover OneWayWebSocket
aqandrew Sep 15, 2026
06bfb69
test(commands): stub getConnectionLogBuffer in Commands mocks
aqandrew Sep 15, 2026
6a574cb
test(commands): type the telemetry mock's getConnectionLogBuffer
aqandrew Sep 15, 2026
ff0f953
fix(commands): keep the buffer on a bundle flush and flush before the…
aqandrew Sep 16, 2026
a3482a5
docs(logging): correct buffer wording, reason label, and changelog
aqandrew Sep 16, 2026
e53aad9
refactor(instrumentation): drop the now-unreachable normal_close reason
aqandrew Sep 16, 2026
1de6ab2
refactor(websocket): collapse the OneWayWebSocket event switches
aqandrew Sep 16, 2026
270ff3b
test(websocket): pin handshakeStatus against real ws and EventSource
aqandrew Sep 16, 2026
5b22f63
refactor(logging): move the connection-failure funnel onto the buffer
aqandrew Sep 16, 2026
a108b82
refactor(api): hoist apiRoute per caller and tidy log-level and route
aqandrew Sep 16, 2026
37d76b0
docs: explain callback EventHandler cast wrt TS' lack of support for …
aqandrew Sep 17, 2026
23e8555
docs: remove mention of socket parking from changelog
aqandrew Sep 17, 2026
a8ca24a
docs: expand `closeRemote` description to include canceled workspace …
aqandrew Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->

## Unreleased

### Added

- Add `coder.connectionLogBuffer.size` to keep recent below-level Coder output
channel entries in memory and replay them into the channel when a connection
fails terminally or you collect a support bundle, so Support can diagnose
connection failures without asking you to reproduce with debug logging on. Set
to `0` to disable.

### Fixed

- Stop retrying after an unrecoverable WebSocket close (`1002`/`1003`) and
replay the buffered connection logs instead. Close codes never reached the
reconnect logic, so these closes retried forever. Server-initiated normal
closes (`1000`/`1001`) keep reconnecting.

## [v1.16.3](https://github.com/coder/vscode-coder/releases/tag/v1.16.3) 2026-09-14

### Changed
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,32 @@ next to the code:

**[`src/instrumentation/CONVENTIONS.md`](src/instrumentation/CONVENTIONS.md)**

## Logging

The extension logs to the "Coder" output channel, a `LogOutputChannel` that gates
messages by the level chosen in its gear menu. To help Support diagnose
connection failures without asking users to reproduce with debug logging enabled,
a `BufferingLogger` ([`src/logging/logBuffer.ts`](src/logging/logBuffer.ts))
wraps the channel and keeps a bounded, in-memory ring of the entries that sit
**below** the current level, which the channel would otherwise drop.

When a WebSocket fails terminally, a remote session closes after a failed or
canceled open, or you collect a support bundle, the extension replays the ring
into the channel. The first physical line of each replayed entry carries a
`[buffered]` marker with its original timestamp and level, and any continuation
lines carry the bare marker.
Capture is best-effort: the channel writes on its own schedule, so a bundle may
miss the most recent lines, but a later failure flush still replays them.
Transient reconnects and intentional teardown never flush, and neither does a
handshake `401` (a 401 explains itself, and with OAuth a refresh reconnects the
same socket). Nothing is recorded or flushed while the channel is at `Off`.

The buffer size is set by `coder.connectionLogBuffer.size` (number of entries;
`0` disables it) and lives in memory, so a hard kill or out-of-memory event
loses it. Extension SSH debug logs that pass through the shared logger are
buffered; the CLI `ProxyCommand` file logs under `coder.proxyLogDirectory` are
not, since support bundles already collect them from disk.

## Testing

There are a few ways you can test the "Open in VS Code" flow:
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@
"minimum": 0,
"default": 250
},
"coder.connectionLogBuffer.size": {
Comment thread
aqandrew marked this conversation as resolved.
"markdownDescription": "Maximum number of log entries below the Coder output channel's log level to keep in memory. When a connection fails terminally or you collect a support bundle, the extension replays them into the channel so a bundle can carry the detail leading up to the failure without debug logging enabled beforehand. Capture is best-effort, since the channel writes on its own schedule. Set to `0` to disable.",
"type": "number",
"minimum": 0,
"maximum": 10000,
"default": 1000
Comment thread
aqandrew marked this conversation as resolved.
},
"coder.httpClientLogLevel": {
"markdownDescription": "Controls the verbosity of HTTP client logging. This affects what details are logged for each HTTP request and response.",
"type": "string",
Expand Down
44 changes: 27 additions & 17 deletions src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ import {
} from "../logging/httpLogger";
import { HttpRequestsTelemetry } from "../logging/httpRequestsTelemetry";
import {
HttpClientLogLevel,
type RequestConfigWithMeta,
type HttpClientLogLevel,
} from "../logging/types";
import { sizeOf } from "../logging/utils";
import { AuthConfigTracker } from "../settings/authConfig";
import { getHeaderCommand } from "../settings/headers";
import { readHttpClientLogLevel } from "../settings/logger";
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
Expand All @@ -48,6 +49,7 @@ import {
type SocketFactory,
} from "../websocket/reconnectingWebSocket";
import { SseConnection } from "../websocket/sseConnection";
import { handshakeStatus } from "../websocket/utils";

import { getRefreshCommand, refreshCertificates } from "./certificateRefresh";
import {
Expand All @@ -69,6 +71,7 @@ import type {
} from "coder/site/src/api/typesGenerated";
import type { ClientOptions } from "ws";

import type { ConnectionStateReason } from "../instrumentation/websocket";
import type { Logger } from "../logging/logger";
import type {
CloseEvent,
Expand Down Expand Up @@ -125,6 +128,10 @@ export class CoderApi extends Api implements vscode.Disposable {
private readonly telemetry: TelemetryReporter,
private readonly httpRequestsTelemetry: HttpRequestsTelemetry,
private readonly authConfigTracker: AuthConfigTracker,
private readonly onConnectionFailure?: (
reason: ConnectionStateReason,
route: string,
) => void,
) {
super();
wrapWithValidation(this);
Expand All @@ -145,6 +152,10 @@ export class CoderApi extends Api implements vscode.Disposable {
token: string | undefined,
output: Logger,
telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER,
onConnectionFailure?: (
reason: ConnectionStateReason,
route: string,
) => void,
): CoderApi {
const httpRequestsTelemetry = new HttpRequestsTelemetry(telemetry);
const authConfigTracker = new AuthConfigTracker();
Expand All @@ -153,6 +164,7 @@ export class CoderApi extends Api implements vscode.Disposable {
telemetry,
httpRequestsTelemetry,
authConfigTracker,
onConnectionFailure,
);
client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS;
client.getAxiosInstance().defaults.headers.common[BAGGAGE_HEADER] =
Expand Down Expand Up @@ -282,9 +294,10 @@ export class CoderApi extends Api implements vscode.Disposable {
watchTargets: string[],
options?: ClientOptions,
) => {
return this.createReconnectingSocket(() =>
const apiRoute = "/api/v2/notifications/inbox/watch";
return this.createReconnectingSocket(apiRoute, () =>
this.createOneWayWebSocket<GetInboxNotificationResponse>({
apiRoute: "/api/v2/notifications/inbox/watch",
apiRoute,
searchParams: {
format: "plaintext",
templates: watchTemplates.join(","),
Expand All @@ -296,9 +309,10 @@ export class CoderApi extends Api implements vscode.Disposable {
};

watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => {
return this.createReconnectingSocket(() =>
const apiRoute = `/api/v2/workspaces/${workspace.id}/watch-ws`;
return this.createReconnectingSocket(apiRoute, () =>
this.createStreamWithSseFallback({
apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`,
apiRoute,
fallbackApiRoute: `/api/v2/workspaces/${workspace.id}/watch`,
options,
}),
Expand All @@ -309,9 +323,10 @@ export class CoderApi extends Api implements vscode.Disposable {
agentId: WorkspaceAgent["id"],
options?: ClientOptions,
) => {
return this.createReconnectingSocket(() =>
const apiRoute = `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`;
return this.createReconnectingSocket(apiRoute, () =>
this.createStreamWithSseFallback({
apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`,
apiRoute,
fallbackApiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata`,
options,
}),
Expand Down Expand Up @@ -530,24 +545,26 @@ export class CoderApi extends Api implements vscode.Disposable {
* Check if an error is a 404 Not Found error.
*/
private is404Error(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return msg.includes(String(HttpStatusCode.NOT_FOUND));
return handshakeStatus(error) === HttpStatusCode.NOT_FOUND;
}

/**
* Create a ReconnectingWebSocket and track it for lifecycle management.
*/
private async createReconnectingSocket<TData>(
apiRoute: string,
socketFactory: SocketFactory<TData>,
): Promise<ReconnectingWebSocket<TData>> {
const options: ReconnectingWebSocketOptions = {
route: apiRoute,
onCertificateRefreshNeeded: async () => {
const refreshCommand = getRefreshCommand();
if (!refreshCommand) {
return false;
}
return refreshCertificates(refreshCommand, this.output);
},
onConnectionFailure: this.onConnectionFailure,
telemetry: this.telemetry,
};

Expand Down Expand Up @@ -814,12 +831,5 @@ function getSize(headers: AxiosHeaders, data: unknown): number | undefined {
}

function getLogLevel(): HttpClientLogLevel {
const logLevelStr = vscode.workspace
.getConfiguration()
.get(
"coder.httpClientLogLevel",
HttpClientLogLevel[HttpClientLogLevel.BASIC],
)
.toUpperCase();
return HttpClientLogLevel[logLevelStr as keyof typeof HttpClientLogLevel];
return readHttpClientLogLevel(vscode.workspace.getConfiguration());
}
8 changes: 8 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import type { MementoManager } from "./core/mementoManager";
import type { PathResolver } from "./core/pathResolver";
import type { SecretsManager, SessionAuth } from "./core/secretsManager";
import type { DeploymentManager } from "./deployment/deploymentManager";
import type { ConnectionLogBuffer } from "./logging/logBuffer";
import type { Logger } from "./logging/logger";
import type { LoginCoordinator, LoginMethod } from "./login/loginCoordinator";
import type { TelemetryService } from "./telemetry/service";
Expand Down Expand Up @@ -168,6 +169,7 @@ export class Commands {
private readonly authTelemetry: AuthTelemetry;
private readonly diagnosticTelemetry: DiagnosticTelemetry;
private readonly workspaceOpenTelemetry: WorkspaceOpenTelemetry;
private readonly connectionLogBuffer: ConnectionLogBuffer;

// These will only be populated when actively connected to a workspace and are
// used in commands. Because commands can be executed by the user, it is not
Expand All @@ -193,6 +195,7 @@ export class Commands {
this.telemetryService,
);
this.logger = serviceContainer.getLogger();
this.connectionLogBuffer = serviceContainer.getConnectionLogBuffer();
this.pathResolver = serviceContainer.getPathResolver();
this.mementoManager = serviceContainer.getMementoManager();
this.secretsManager = serviceContainer.getSecretsManager();
Expand Down Expand Up @@ -486,6 +489,11 @@ export class Commands {
: [];

progress.report({ message: "Collecting diagnostics..." });
// Flush buffered below-level connection logs before the CLI runs so
// the channel has time to write them to disk; retain the ring so a
// later failure flush still replays them. Best-effort: the channel
// writes on its own schedule, so the tail may not land in this bundle.
this.connectionLogBuffer.flush("support_bundle", { retain: true });
await cliExec.supportBundle(env, workspaceId, {
outputPath: outputUri.fsPath,
agentName,
Expand Down
34 changes: 31 additions & 3 deletions src/core/container.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import * as vscode from "vscode";

import { watchConfigurationChanges } from "../configWatcher";
import { AuthTelemetry } from "../instrumentation/auth";
import {
BufferingLogger,
type ConnectionLogBuffer,
} from "../logging/logBuffer";
import { prefixLogger } from "../logging/prefixLogger";
import { shortId } from "../logging/utils";
import { LoginCoordinator } from "../login/loginCoordinator";
import { OAuthCallback } from "../oauth/oauthCallback";
import {
CONNECTION_LOG_BUFFER_SIZE_SETTING,
readConnectionLogBufferSize,
} from "../settings/logger";
import { buildSession, extractExtensionVersion } from "../telemetry/event";
import { TelemetryService } from "../telemetry/service";
import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink";
Expand All @@ -29,7 +38,8 @@ import type { Logger } from "../logging/logger";
*/
export class ServiceContainer implements vscode.Disposable {
private readonly outputChannel: vscode.LogOutputChannel;
private readonly logger: Logger;
private readonly logger: BufferingLogger;
private readonly connectionLogBufferConfigSubscription: vscode.Disposable;
private readonly pathResolver: PathResolver;
private readonly mementoManager: MementoManager;
private readonly secretsManager: SecretsManager;
Expand All @@ -48,9 +58,21 @@ export class ServiceContainer implements vscode.Disposable {
this.outputChannel = vscode.window.createOutputChannel("Coder", {
log: true,
});
this.logger = prefixLogger(
const readSize = () =>
readConnectionLogBufferSize(vscode.workspace.getConfiguration());
this.logger = new BufferingLogger(
prefixLogger(this.outputChannel, `[session ${shortId(sessionId)}]`),
this.outputChannel,
`[session ${shortId(sessionId)}]`,
readSize(),
);
this.connectionLogBufferConfigSubscription = watchConfigurationChanges(
[
{
setting: CONNECTION_LOG_BUFFER_SIZE_SETTING,
getValue: readSize,
},
],
() => this.logger.setCapacity(readSize()),
);
this.pathResolver = new PathResolver(
context.globalStorageUri.fsPath,
Expand Down Expand Up @@ -148,6 +170,11 @@ export class ServiceContainer implements vscode.Disposable {
return this.logger;
}

/** The connection log buffer that replays below-level entries on failure. */
getConnectionLogBuffer(): ConnectionLogBuffer {
return this.logger;
}

getCliManager(): CliManager {
return this.cliManager;
}
Expand Down Expand Up @@ -193,6 +220,7 @@ export class ServiceContainer implements vscode.Disposable {
this.commandManager.dispose();
this.contextManager.dispose();
this.loginCoordinator.dispose();
this.connectionLogBufferConfigSubscription.dispose();
try {
await this.telemetryService.dispose();
} finally {
Expand Down
1 change: 1 addition & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ async function doActivate(
deploymentSessionAuth?.token,
output,
telemetryService,
serviceContainer.getConnectionLogBuffer().onConnectionFailure,
);
ctx.subscriptions.push(client);

Expand Down
12 changes: 6 additions & 6 deletions src/instrumentation/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ Emitted by `WebSocketTelemetry`.
These events share one value set, **ConnectionStateReason**: `initial_connect`,
`manual_reconnect`, `certificate_refresh`, `scheduled_reconnect`, `open`,
`disconnect`, `dispose`, `unrecoverable_close`, `unrecoverable_http`,
`certificate_error`, `connection_error`, `normal_close`, `unexpected_close`.
`certificate_error`, `connection_error`, `unexpected_close`.

### Logs

Expand All @@ -451,11 +451,11 @@ These events share one value set, **ConnectionStateReason**: `initial_connect`,
Emitted as an error log (with the `error` block) when a socket error caused
the drop.

| Attribute | Values |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `cause` | `manual_disconnect`, `replaced`, `unrecoverable_close`, `normal_close`, `unexpected_close`, `disposed`, `error` |
| `close_code` | WebSocket close code (when known) |
| `connection_duration_ms` (measurement) | time the connection was open |
| Attribute | Values |
| -------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `cause` | `manual_disconnect`, `replaced`, `unrecoverable_close`, `unexpected_close`, `disposed`, `error` |
| `close_code` | WebSocket close code (when known) |
| `connection_duration_ms` (measurement) | time the connection was open |

#### `connection.reconnect_resolved`

Expand Down
2 changes: 0 additions & 2 deletions src/instrumentation/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ export type ConnectionStateReason =
| "unrecoverable_http"
| "certificate_error"
| "connection_error"
| "normal_close"
| "unexpected_close";

export type ConnectionDropCause =
| "manual_disconnect"
| "replaced"
| "unrecoverable_close"
| "normal_close"
| "unexpected_close"
| "disposed"
| "error";
Expand Down
1 change: 1 addition & 0 deletions src/logging/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const SENSITIVE_BODY_FIELDS: ReadonlySet<Lowercase<string>> = new Set([
"id_token",
"password",
"refresh_token",
"registration_access_token",
"token",
]);

Expand Down
Loading