From 29cdab9eff3995f525244315ee7508824f0163b2 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 22:17:03 +0000 Subject: [PATCH 1/7] feat(logging): tag logs with a per-session ID Add SessionLogger, which wraps the Coder output channel and prefixes every message with the activation's session ID so all log lines for a session can be correlated by a single ID. Generate the ID once in the ServiceContainer, reuse it as the telemetry session ID, and expose it via getSessionId() for downstream consumers. --- src/core/container.ts | 21 ++++++-- src/logging/sessionLogger.ts | 44 ++++++++++++++++ test/unit/logging/sessionLogger.test.ts | 67 +++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/logging/sessionLogger.ts create mode 100644 test/unit/logging/sessionLogger.test.ts diff --git a/src/core/container.ts b/src/core/container.ts index 02fbd70b3e..3520263fb6 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { AuthTelemetry } from "../instrumentation/auth"; +import { SessionLogger } from "../logging/sessionLogger"; import { LoginCoordinator } from "../login/loginCoordinator"; import { OAuthCallback } from "../oauth/oauthCallback"; import { buildSession, extractExtensionVersion } from "../telemetry/event"; @@ -26,7 +27,9 @@ import type { Logger } from "../logging/logger"; * Centralizes the creation and management of all core services. */ export class ServiceContainer implements vscode.Disposable { - private readonly logger: vscode.LogOutputChannel; + private readonly outputChannel: vscode.LogOutputChannel; + private readonly sessionId: string; + private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; @@ -42,7 +45,13 @@ export class ServiceContainer implements vscode.Disposable { private readonly commandManager: CommandManager; constructor(context: vscode.ExtensionContext) { - this.logger = vscode.window.createOutputChannel("Coder", { log: true }); + this.outputChannel = vscode.window.createOutputChannel("Coder", { + log: true, + }); + // One session ID per activation, shared by logs, API requests, + // telemetry, and the CLI so all data for a session correlates. + this.sessionId = newSessionId(); + this.logger = new SessionLogger(this.outputChannel, this.sessionId); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, context.logUri.fsPath, @@ -56,7 +65,7 @@ export class ServiceContainer implements vscode.Disposable { const session = buildSession( extractExtensionVersion(context.extension.packageJSON), - newSessionId(), + this.sessionId, ); const localJsonlSink = LocalJsonlSink.start( { @@ -139,6 +148,10 @@ export class ServiceContainer implements vscode.Disposable { return this.logger; } + getSessionId(): string { + return this.sessionId; + } + getCliManager(): CliManager { return this.cliManager; } @@ -187,7 +200,7 @@ export class ServiceContainer implements vscode.Disposable { try { await this.telemetryService.dispose(); } finally { - this.logger.dispose(); + this.outputChannel.dispose(); } } } diff --git a/src/logging/sessionLogger.ts b/src/logging/sessionLogger.ts new file mode 100644 index 0000000000..fce6a43e91 --- /dev/null +++ b/src/logging/sessionLogger.ts @@ -0,0 +1,44 @@ +import type * as vscode from "vscode"; + +import type { Logger } from "./logger"; + +/** + * Wraps a {@link Logger} and prefixes every message with the session ID so all + * log lines produced during a session can be correlated by searching for a + * single ID. Composition, not inheritance: it forwards to the underlying + * channel after tagging the message. + */ +export class SessionLogger implements Logger { + constructor( + private readonly inner: vscode.LogOutputChannel, + private readonly sessionId: string, + ) {} + + private prefix(message: string): string { + return `[${this.sessionId}] ${message}`; + } + + trace(message: string, ...args: unknown[]): void { + this.inner.trace(this.prefix(message), ...args); + } + + debug(message: string, ...args: unknown[]): void { + this.inner.debug(this.prefix(message), ...args); + } + + info(message: string, ...args: unknown[]): void { + this.inner.info(this.prefix(message), ...args); + } + + warn(message: string, ...args: unknown[]): void { + this.inner.warn(this.prefix(message), ...args); + } + + error(message: string, ...args: unknown[]): void { + this.inner.error(this.prefix(message), ...args); + } + + show(): void { + this.inner.show(); + } +} diff --git a/test/unit/logging/sessionLogger.test.ts b/test/unit/logging/sessionLogger.test.ts new file mode 100644 index 0000000000..289067a338 --- /dev/null +++ b/test/unit/logging/sessionLogger.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SessionLogger } from "@/logging/sessionLogger"; + +import type * as vscode from "vscode"; + +function createMockOutputChannel() { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + show: vi.fn(), + } as unknown as vscode.LogOutputChannel & { + trace: ReturnType; + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + show: ReturnType; + }; +} + +const SESSION_ID = "0123456789abcdef0123456789abcdef"; + +describe("SessionLogger", () => { + it("prefixes every level with the session ID", () => { + const channel = createMockOutputChannel(); + const logger = new SessionLogger(channel, SESSION_ID); + + logger.trace("trace msg"); + logger.debug("debug msg"); + logger.info("info msg"); + logger.warn("warn msg"); + logger.error("error msg"); + + expect(channel.trace).toHaveBeenCalledWith(`[${SESSION_ID}] trace msg`); + expect(channel.debug).toHaveBeenCalledWith(`[${SESSION_ID}] debug msg`); + expect(channel.info).toHaveBeenCalledWith(`[${SESSION_ID}] info msg`); + expect(channel.warn).toHaveBeenCalledWith(`[${SESSION_ID}] warn msg`); + expect(channel.error).toHaveBeenCalledWith(`[${SESSION_ID}] error msg`); + }); + + it("forwards additional arguments unchanged", () => { + const channel = createMockOutputChannel(); + const logger = new SessionLogger(channel, SESSION_ID); + const err = new Error("boom"); + + logger.error("failed", err, 42); + + expect(channel.error).toHaveBeenCalledWith( + `[${SESSION_ID}] failed`, + err, + 42, + ); + }); + + it("delegates show() to the underlying channel", () => { + const channel = createMockOutputChannel(); + const logger = new SessionLogger(channel, SESSION_ID); + + logger.show(); + + expect(channel.show).toHaveBeenCalledOnce(); + }); +}); From f4967de3949ce075c9a22f131482a7989975ffb7 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 12 Aug 2026 14:50:28 -0700 Subject: [PATCH 2/7] docs: remove waffle --- src/logging/sessionLogger.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/logging/sessionLogger.ts b/src/logging/sessionLogger.ts index fce6a43e91..aae4b86c00 100644 --- a/src/logging/sessionLogger.ts +++ b/src/logging/sessionLogger.ts @@ -5,8 +5,7 @@ import type { Logger } from "./logger"; /** * Wraps a {@link Logger} and prefixes every message with the session ID so all * log lines produced during a session can be correlated by searching for a - * single ID. Composition, not inheritance: it forwards to the underlying - * channel after tagging the message. + * single ID. */ export class SessionLogger implements Logger { constructor( From 615ea3bef79e1594be9e49cb36c31983e08af1d8 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 12 Aug 2026 14:52:50 -0700 Subject: [PATCH 3/7] refactor: rename inner to outputChannel --- src/logging/sessionLogger.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/logging/sessionLogger.ts b/src/logging/sessionLogger.ts index aae4b86c00..929d4be7b3 100644 --- a/src/logging/sessionLogger.ts +++ b/src/logging/sessionLogger.ts @@ -9,7 +9,7 @@ import type { Logger } from "./logger"; */ export class SessionLogger implements Logger { constructor( - private readonly inner: vscode.LogOutputChannel, + private readonly outputChannel: vscode.LogOutputChannel, private readonly sessionId: string, ) {} @@ -18,26 +18,26 @@ export class SessionLogger implements Logger { } trace(message: string, ...args: unknown[]): void { - this.inner.trace(this.prefix(message), ...args); + this.outputChannel.trace(this.prefix(message), ...args); } debug(message: string, ...args: unknown[]): void { - this.inner.debug(this.prefix(message), ...args); + this.outputChannel.debug(this.prefix(message), ...args); } info(message: string, ...args: unknown[]): void { - this.inner.info(this.prefix(message), ...args); + this.outputChannel.info(this.prefix(message), ...args); } warn(message: string, ...args: unknown[]): void { - this.inner.warn(this.prefix(message), ...args); + this.outputChannel.warn(this.prefix(message), ...args); } error(message: string, ...args: unknown[]): void { - this.inner.error(this.prefix(message), ...args); + this.outputChannel.error(this.prefix(message), ...args); } show(): void { - this.inner.show(); + this.outputChannel.show(); } } From 8e091968219cd94650db7496a88dc72bc218f04b Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 16:28:38 -0700 Subject: [PATCH 4/7] chore: rm unused getSessionId function --- src/core/container.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/core/container.ts b/src/core/container.ts index 3520263fb6..1f0100eefd 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -148,10 +148,6 @@ export class ServiceContainer implements vscode.Disposable { return this.logger; } - getSessionId(): string { - return this.sessionId; - } - getCliManager(): CliManager { return this.cliManager; } From 94e9563b0542d00f6d8e1f49565b078c4b34b751 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 16:51:16 -0700 Subject: [PATCH 5/7] refactor: make SessionLogger's inner a Logger instead of a vscode.LogOutputChannel named outputChannel --- src/logging/sessionLogger.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/logging/sessionLogger.ts b/src/logging/sessionLogger.ts index 929d4be7b3..7a090e538d 100644 --- a/src/logging/sessionLogger.ts +++ b/src/logging/sessionLogger.ts @@ -1,5 +1,3 @@ -import type * as vscode from "vscode"; - import type { Logger } from "./logger"; /** @@ -9,7 +7,7 @@ import type { Logger } from "./logger"; */ export class SessionLogger implements Logger { constructor( - private readonly outputChannel: vscode.LogOutputChannel, + private readonly inner: Logger, private readonly sessionId: string, ) {} @@ -18,26 +16,26 @@ export class SessionLogger implements Logger { } trace(message: string, ...args: unknown[]): void { - this.outputChannel.trace(this.prefix(message), ...args); + this.inner.trace(this.prefix(message), ...args); } debug(message: string, ...args: unknown[]): void { - this.outputChannel.debug(this.prefix(message), ...args); + this.inner.debug(this.prefix(message), ...args); } info(message: string, ...args: unknown[]): void { - this.outputChannel.info(this.prefix(message), ...args); + this.inner.info(this.prefix(message), ...args); } warn(message: string, ...args: unknown[]): void { - this.outputChannel.warn(this.prefix(message), ...args); + this.inner.warn(this.prefix(message), ...args); } error(message: string, ...args: unknown[]): void { - this.outputChannel.error(this.prefix(message), ...args); + this.inner.error(this.prefix(message), ...args); } show(): void { - this.outputChannel.show(); + this.inner.show(); } } From 7786bcc00b969be2f2940d5d0700d352a49c2c33 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 16:54:23 -0700 Subject: [PATCH 6/7] test: initialize SessionLoggers with createMockLogger --- test/unit/logging/sessionLogger.test.ts | 54 ++++++++----------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/test/unit/logging/sessionLogger.test.ts b/test/unit/logging/sessionLogger.test.ts index 289067a338..f5b1ad85d5 100644 --- a/test/unit/logging/sessionLogger.test.ts +++ b/test/unit/logging/sessionLogger.test.ts @@ -1,33 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { SessionLogger } from "@/logging/sessionLogger"; -import type * as vscode from "vscode"; - -function createMockOutputChannel() { - return { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - show: vi.fn(), - } as unknown as vscode.LogOutputChannel & { - trace: ReturnType; - debug: ReturnType; - info: ReturnType; - warn: ReturnType; - error: ReturnType; - show: ReturnType; - }; -} +import { createMockLogger } from "../../mocks/testHelpers"; const SESSION_ID = "0123456789abcdef0123456789abcdef"; describe("SessionLogger", () => { it("prefixes every level with the session ID", () => { - const channel = createMockOutputChannel(); - const logger = new SessionLogger(channel, SESSION_ID); + const inner = createMockLogger(); + const logger = new SessionLogger(inner, SESSION_ID); logger.trace("trace msg"); logger.debug("debug msg"); @@ -35,33 +17,29 @@ describe("SessionLogger", () => { logger.warn("warn msg"); logger.error("error msg"); - expect(channel.trace).toHaveBeenCalledWith(`[${SESSION_ID}] trace msg`); - expect(channel.debug).toHaveBeenCalledWith(`[${SESSION_ID}] debug msg`); - expect(channel.info).toHaveBeenCalledWith(`[${SESSION_ID}] info msg`); - expect(channel.warn).toHaveBeenCalledWith(`[${SESSION_ID}] warn msg`); - expect(channel.error).toHaveBeenCalledWith(`[${SESSION_ID}] error msg`); + expect(inner.trace).toHaveBeenCalledWith(`[${SESSION_ID}] trace msg`); + expect(inner.debug).toHaveBeenCalledWith(`[${SESSION_ID}] debug msg`); + expect(inner.info).toHaveBeenCalledWith(`[${SESSION_ID}] info msg`); + expect(inner.warn).toHaveBeenCalledWith(`[${SESSION_ID}] warn msg`); + expect(inner.error).toHaveBeenCalledWith(`[${SESSION_ID}] error msg`); }); it("forwards additional arguments unchanged", () => { - const channel = createMockOutputChannel(); - const logger = new SessionLogger(channel, SESSION_ID); + const inner = createMockLogger(); + const logger = new SessionLogger(inner, SESSION_ID); const err = new Error("boom"); logger.error("failed", err, 42); - expect(channel.error).toHaveBeenCalledWith( - `[${SESSION_ID}] failed`, - err, - 42, - ); + expect(inner.error).toHaveBeenCalledWith(`[${SESSION_ID}] failed`, err, 42); }); - it("delegates show() to the underlying channel", () => { - const channel = createMockOutputChannel(); - const logger = new SessionLogger(channel, SESSION_ID); + it("delegates show() to the underlying logger", () => { + const inner = createMockLogger(); + const logger = new SessionLogger(inner, SESSION_ID); logger.show(); - expect(channel.show).toHaveBeenCalledOnce(); + expect(inner.show).toHaveBeenCalledOnce(); }); }); From e5dec0e55944679ddaa15e1c1835aa94c0052f5a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 14 Aug 2026 00:01:53 +0000 Subject: [PATCH 7/7] refactor(logging): convert SessionLogger to functional prefixLogger Replace the SessionLogger class with a prefixLogger(inner, prefix) factory that wraps a Logger and prefixes every message, and rename the module to prefixLogger.ts. The prefix is now generic (a session ID, a workspace name, etc.); the caller passes the bracketed session prefix. Addresses review feedback on #1073. --- src/core/container.ts | 4 +-- src/logging/prefixLogger.ts | 18 ++++++++++ src/logging/sessionLogger.ts | 41 ---------------------- test/unit/logging/prefixLogger.test.ts | 45 +++++++++++++++++++++++++ test/unit/logging/sessionLogger.test.ts | 45 ------------------------- 5 files changed, 65 insertions(+), 88 deletions(-) create mode 100644 src/logging/prefixLogger.ts delete mode 100644 src/logging/sessionLogger.ts create mode 100644 test/unit/logging/prefixLogger.test.ts delete mode 100644 test/unit/logging/sessionLogger.test.ts diff --git a/src/core/container.ts b/src/core/container.ts index 1f0100eefd..9c3f484405 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { AuthTelemetry } from "../instrumentation/auth"; -import { SessionLogger } from "../logging/sessionLogger"; +import { prefixLogger } from "../logging/prefixLogger"; import { LoginCoordinator } from "../login/loginCoordinator"; import { OAuthCallback } from "../oauth/oauthCallback"; import { buildSession, extractExtensionVersion } from "../telemetry/event"; @@ -51,7 +51,7 @@ export class ServiceContainer implements vscode.Disposable { // One session ID per activation, shared by logs, API requests, // telemetry, and the CLI so all data for a session correlates. this.sessionId = newSessionId(); - this.logger = new SessionLogger(this.outputChannel, this.sessionId); + this.logger = prefixLogger(this.outputChannel, `[${this.sessionId}]`); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, context.logUri.fsPath, diff --git a/src/logging/prefixLogger.ts b/src/logging/prefixLogger.ts new file mode 100644 index 0000000000..1cf4d2ce12 --- /dev/null +++ b/src/logging/prefixLogger.ts @@ -0,0 +1,18 @@ +import type { Logger } from "./logger"; + +/** + * Wraps a {@link Logger} so every message is prefixed, letting all lines that + * share a prefix (a session ID, a workspace name) be found with one search. + * Extra arguments are forwarded untouched. + */ +export function prefixLogger(inner: Logger, prefix: string): Logger { + const tag = (message: string) => `${prefix} ${message}`; + return { + trace: (message, ...args) => inner.trace(tag(message), ...args), + debug: (message, ...args) => inner.debug(tag(message), ...args), + info: (message, ...args) => inner.info(tag(message), ...args), + warn: (message, ...args) => inner.warn(tag(message), ...args), + error: (message, ...args) => inner.error(tag(message), ...args), + show: () => inner.show(), + }; +} diff --git a/src/logging/sessionLogger.ts b/src/logging/sessionLogger.ts deleted file mode 100644 index 7a090e538d..0000000000 --- a/src/logging/sessionLogger.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Logger } from "./logger"; - -/** - * Wraps a {@link Logger} and prefixes every message with the session ID so all - * log lines produced during a session can be correlated by searching for a - * single ID. - */ -export class SessionLogger implements Logger { - constructor( - private readonly inner: Logger, - private readonly sessionId: string, - ) {} - - private prefix(message: string): string { - return `[${this.sessionId}] ${message}`; - } - - trace(message: string, ...args: unknown[]): void { - this.inner.trace(this.prefix(message), ...args); - } - - debug(message: string, ...args: unknown[]): void { - this.inner.debug(this.prefix(message), ...args); - } - - info(message: string, ...args: unknown[]): void { - this.inner.info(this.prefix(message), ...args); - } - - warn(message: string, ...args: unknown[]): void { - this.inner.warn(this.prefix(message), ...args); - } - - error(message: string, ...args: unknown[]): void { - this.inner.error(this.prefix(message), ...args); - } - - show(): void { - this.inner.show(); - } -} diff --git a/test/unit/logging/prefixLogger.test.ts b/test/unit/logging/prefixLogger.test.ts new file mode 100644 index 0000000000..7fbf0fad9d --- /dev/null +++ b/test/unit/logging/prefixLogger.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { prefixLogger } from "@/logging/prefixLogger"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +const PREFIX = "[0123456789abcdef0123456789abcdef]"; + +describe("prefixLogger", () => { + it("prefixes every level with the given prefix", () => { + const inner = createMockLogger(); + const logger = prefixLogger(inner, PREFIX); + + logger.trace("trace msg"); + logger.debug("debug msg"); + logger.info("info msg"); + logger.warn("warn msg"); + logger.error("error msg"); + + expect(inner.trace).toHaveBeenCalledWith(`${PREFIX} trace msg`); + expect(inner.debug).toHaveBeenCalledWith(`${PREFIX} debug msg`); + expect(inner.info).toHaveBeenCalledWith(`${PREFIX} info msg`); + expect(inner.warn).toHaveBeenCalledWith(`${PREFIX} warn msg`); + expect(inner.error).toHaveBeenCalledWith(`${PREFIX} error msg`); + }); + + it("forwards additional arguments unchanged", () => { + const inner = createMockLogger(); + const logger = prefixLogger(inner, PREFIX); + const err = new Error("boom"); + + logger.error("failed", err, 42); + + expect(inner.error).toHaveBeenCalledWith(`${PREFIX} failed`, err, 42); + }); + + it("delegates show() to the underlying logger", () => { + const inner = createMockLogger(); + const logger = prefixLogger(inner, PREFIX); + + logger.show(); + + expect(inner.show).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/logging/sessionLogger.test.ts b/test/unit/logging/sessionLogger.test.ts deleted file mode 100644 index f5b1ad85d5..0000000000 --- a/test/unit/logging/sessionLogger.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { SessionLogger } from "@/logging/sessionLogger"; - -import { createMockLogger } from "../../mocks/testHelpers"; - -const SESSION_ID = "0123456789abcdef0123456789abcdef"; - -describe("SessionLogger", () => { - it("prefixes every level with the session ID", () => { - const inner = createMockLogger(); - const logger = new SessionLogger(inner, SESSION_ID); - - logger.trace("trace msg"); - logger.debug("debug msg"); - logger.info("info msg"); - logger.warn("warn msg"); - logger.error("error msg"); - - expect(inner.trace).toHaveBeenCalledWith(`[${SESSION_ID}] trace msg`); - expect(inner.debug).toHaveBeenCalledWith(`[${SESSION_ID}] debug msg`); - expect(inner.info).toHaveBeenCalledWith(`[${SESSION_ID}] info msg`); - expect(inner.warn).toHaveBeenCalledWith(`[${SESSION_ID}] warn msg`); - expect(inner.error).toHaveBeenCalledWith(`[${SESSION_ID}] error msg`); - }); - - it("forwards additional arguments unchanged", () => { - const inner = createMockLogger(); - const logger = new SessionLogger(inner, SESSION_ID); - const err = new Error("boom"); - - logger.error("failed", err, 42); - - expect(inner.error).toHaveBeenCalledWith(`[${SESSION_ID}] failed`, err, 42); - }); - - it("delegates show() to the underlying logger", () => { - const inner = createMockLogger(); - const logger = new SessionLogger(inner, SESSION_ID); - - logger.show(); - - expect(inner.show).toHaveBeenCalledOnce(); - }); -});