From e5ae4b9e101d13c41e5b9eb67cac04881a032fc6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 22:17:03 +0000 Subject: [PATCH 1/8] 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 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/container.ts b/src/core/container.ts index 9c3f48440..64912fec9 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -148,6 +148,10 @@ export class ServiceContainer implements vscode.Disposable { return this.logger; } + getSessionId(): string { + return this.sessionId; + } + getCliManager(): CliManager { return this.cliManager; } From 99d66e3e11dcdf84e96a0937e620c5ab3a25b581 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 22:22:55 +0000 Subject: [PATCH 2/8] feat: propagate the session ID to requests and the CLI Attach the session ID to every API request via the W3C baggage header (session_id=) so the server can correlate requests with the session's logs and telemetry, threading it through CoderApi.create at all call sites. Set CODER_TRACE_SESSION_ID on both process.env and the terminal environment collection so the spawned `coder ssh` ProxyCommand reuses the plugin's session ID instead of generating its own. --- src/api/coderApi.ts | 17 ++++++++++++++- src/core/container.ts | 1 + src/deployment/deploymentManager.ts | 10 ++++++++- src/extension.ts | 1 + src/login/loginCoordinator.ts | 18 ++++++++++++++-- src/oauth/authorizer.ts | 9 +++++++- src/oauth/sessionManager.ts | 18 ++++++++++++++-- src/remote/environment.ts | 23 ++++++++++++-------- src/remote/remote.ts | 2 ++ test/mocks/testHelpers.ts | 1 + test/unit/api/coderApi.test.ts | 25 ++++++++++++++++++++++ test/unit/login/loginCoordinator.test.ts | 1 + test/unit/oauth/authorizer.test.ts | 1 + test/unit/remote/environment.test.ts | 27 +++++++++++++++++------- 14 files changed, 130 insertions(+), 24 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 7b02b9b24..15d3564c8 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -77,6 +77,9 @@ import type { const coderSessionTokenHeader = "Coder-Session-Token"; +/** W3C baggage header used to propagate the session ID to the server. */ +const baggageHeader = "baggage"; + /** * Default timeout for REST requests, so requests hung on half-open TCP * connections (e.g. after system sleep) don't stall pollers forever. @@ -119,6 +122,7 @@ export class CoderApi extends Api implements vscode.Disposable { private readonly telemetry: TelemetryReporter, private readonly httpRequestsTelemetry: HttpRequestsTelemetry, private readonly authConfigTracker: AuthConfigTracker, + private readonly sessionId: string | undefined, ) { super(); wrapWithValidation(this); @@ -130,13 +134,16 @@ export class CoderApi extends Api implements vscode.Disposable { * Automatically sets up logging interceptors, certificate handling, * HTTP request telemetry, and WebSocket connection telemetry. All * telemetry routes through the single reporter passed in (defaults to - * NOOP_TELEMETRY_REPORTER for throwaway clients). + * NOOP_TELEMETRY_REPORTER for throwaway clients). When a session ID is + * provided it is attached to every request via the `baggage` header so the + * server can correlate requests with the session's logs and telemetry. */ static create( baseUrl: string, token: string | undefined, output: Logger, telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER, + sessionId?: string, ): CoderApi { const httpRequestsTelemetry = new HttpRequestsTelemetry(telemetry); const authConfigTracker = new AuthConfigTracker(); @@ -145,8 +152,13 @@ export class CoderApi extends Api implements vscode.Disposable { telemetry, httpRequestsTelemetry, authConfigTracker, + sessionId, ); client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS; + if (sessionId) { + client.getAxiosInstance().defaults.headers.common[baggageHeader] = + `session_id=${sessionId}`; + } client.setCredentials(baseUrl, token); setupInterceptors(client, output, httpRequestsTelemetry, authConfigTracker); @@ -379,6 +391,9 @@ export class CoderApi extends Api implements vscode.Disposable { */ const headers = { ...(token ? { [coderSessionTokenHeader]: token } : {}), + ...(this.sessionId + ? { [baggageHeader]: `session_id=${this.sessionId}` } + : {}), ...configs.options?.headers, ...headersFromCommand, }; diff --git a/src/core/container.ts b/src/core/container.ts index 64912fec9..431b807bc 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -114,6 +114,7 @@ export class ServiceContainer implements vscode.Disposable { new AuthTelemetry(this.telemetryService), this.oauthCallback, context.extension.id, + this.sessionId, ); this.duplicateWorkspaceIpc = new DuplicateWorkspaceIpc( context.secrets, diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 7c0c47e3e..a17f13722 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -49,6 +49,7 @@ export class DeploymentManager implements vscode.Disposable { private readonly logger: Logger; private readonly telemetryService: TelemetryService; private readonly deploymentTelemetry: DeploymentTelemetry; + private readonly sessionId: string; readonly #sessionStore = new SessionStore(); #disposed = false; @@ -69,6 +70,7 @@ export class DeploymentManager implements vscode.Disposable { this.logger = serviceContainer.getLogger(); this.telemetryService = serviceContainer.getTelemetryService(); this.deploymentTelemetry = new DeploymentTelemetry(this.telemetryService); + this.sessionId = serviceContainer.getSessionId(); } public static create( @@ -141,7 +143,13 @@ export class DeploymentManager implements vscode.Disposable { url: string, token: string | undefined, ): Promise { - const tempClient = CoderApi.create(url, token, this.logger); + const tempClient = CoderApi.create( + url, + token, + this.logger, + undefined, + this.sessionId, + ); try { return await tempClient.getAuthenticatedUser(); } finally { diff --git a/src/extension.ts b/src/extension.ts index 6dc82209e..dae226b28 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -141,6 +141,7 @@ async function doActivate( deploymentSessionAuth?.token, output, telemetryService, + serviceContainer.getSessionId(), ); ctx.subscriptions.push(client); diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 15c4fd93f..457e868ab 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -91,12 +91,14 @@ export class LoginCoordinator implements vscode.Disposable { private readonly authTelemetry: AuthTelemetry, oauthCallback: OAuthCallback, extensionId: string, + private readonly sessionId: string, ) { this.oauthAuthorizer = new OAuthAuthorizer( secretsManager, oauthCallback, logger, extensionId, + sessionId, ); } @@ -248,7 +250,13 @@ export class LoginCoordinator implements vscode.Disposable { safeHostname, async (auth) => { if (auth?.token) { - const client = CoderApi.create(auth.url, auth.token, this.logger); + const client = CoderApi.create( + auth.url, + auth.token, + this.logger, + undefined, + this.sessionId, + ); try { const user = await client.getAuthenticatedUser(); // Stop listening only on success; a bad token shouldn't @@ -284,7 +292,13 @@ export class LoginCoordinator implements vscode.Disposable { providedToken?: string, tokenSignInConfirmed = false, ): Promise { - const client = CoderApi.create(deployment.url, "", this.logger); + const client = CoderApi.create( + deployment.url, + "", + this.logger, + undefined, + this.sessionId, + ); try { return await this.runLoginAttempts( client, diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index b22bb90a7..0420bbba4 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -51,6 +51,7 @@ export class OAuthAuthorizer implements vscode.Disposable { private readonly oauthCallback: OAuthCallback, private readonly logger: Logger, private readonly extensionId: string, + private readonly sessionId: string, ) {} /** @@ -63,7 +64,13 @@ export class OAuthAuthorizer implements vscode.Disposable { progress: vscode.Progress<{ message?: string; increment?: number }>, cancellationToken: vscode.CancellationToken, ): Promise<{ tokenResponse: OAuth2TokenResponse; user: User }> { - const client = CoderApi.create(deployment.url, undefined, this.logger); + const client = CoderApi.create( + deployment.url, + undefined, + this.logger, + undefined, + this.sessionId, + ); try { return await this.runLoginFlow( client, diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index 5ccc122aa..baacf2b09 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -70,6 +70,7 @@ export class OAuthSessionManager implements vscode.Disposable { container.getLogger(), onAuthRequired, new AuthTelemetry(container.getTelemetryService()), + container.getSessionId(), ); manager.setupTokenListener(); manager.scheduleNextRefresh(); @@ -82,6 +83,7 @@ export class OAuthSessionManager implements vscode.Disposable { private readonly logger: Logger, private readonly onAuthRequired: () => Promise, private readonly authTelemetry: AuthTelemetry, + private readonly sessionId: string, ) {} /** @@ -299,7 +301,13 @@ export class OAuthSessionManager implements vscode.Disposable { }) => Promise, ): Promise { const deployment = this.requireDeployment(); - const client = CoderApi.create(deployment.url, token, this.logger); + const client = CoderApi.create( + deployment.url, + token, + this.logger, + undefined, + this.sessionId, + ); try { const axiosInstance = client.getAxiosInstance(); const metadataClient = new OAuthMetadataClient( @@ -459,7 +467,13 @@ export class OAuthSessionManager implements vscode.Disposable { deployment: Deployment, accessToken: string, ): Promise { - const client = CoderApi.create(deployment.url, accessToken, this.logger); + const client = CoderApi.create( + deployment.url, + accessToken, + this.logger, + undefined, + this.sessionId, + ); try { return (await client.getAuthenticatedUser()).username; } catch (error) { diff --git a/src/remote/environment.ts b/src/remote/environment.ts index ab9e35c84..4c913c13f 100644 --- a/src/remote/environment.ts +++ b/src/remote/environment.ts @@ -26,13 +26,14 @@ export const SSH_PROXY_SETTINGS: ReadonlyArray<{ /** * Apply the SSH environment that the spawned `coder ssh` ProxyCommand inherits. - * Currently just the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the - * coder CLI like any Go HTTP client. Applied via both process.env (ssh spawned as - * a child, `remote.SSH.useLocalServer=true`) and the terminal env collection (ssh - * spawned in a terminal, `useLocalServer=false`, which can't see process.env), - * since the mode isn't knowable up front. Mutating env rather than the SSH config - * keeps credentialed URLs off disk and windows independent. Disposable restores - * both. + * Includes the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the coder + * CLI like any Go HTTP client, and the session ID via CODER_TRACE_SESSION_ID so + * the CLI reuses the plugin's session ID instead of generating its own. Applied + * via both process.env (ssh spawned as a child, `remote.SSH.useLocalServer=true`) + * and the terminal env collection (ssh spawned in a terminal, + * `useLocalServer=false`, which can't see process.env), since the mode isn't + * knowable up front. Mutating env rather than the SSH config keeps credentialed + * URLs off disk and windows independent. Disposable restores both. */ export function applySshEnvironment( cfg: Pick, @@ -40,9 +41,13 @@ export function applySshEnvironment( GlobalEnvironmentVariableCollection, "persistent" | "replace" | "clear" >, + sessionId: string, env: Environment = process.env, ): { dispose(): void } { - const values = getSshProxyEnvironment(cfg); + const values: Environment = { + ...getSshProxyEnvironment(cfg), + CODER_TRACE_SESSION_ID: sessionId, + }; const restoreEnv = applyEnvironment(values, env); collection.persistent = false; @@ -83,7 +88,7 @@ export function getSshProxyEnvironment( } function applyEnvironment( - values: SshEnvironment, + values: Environment, env: Environment, ): { dispose(): void } { // Stored `undefined` means the key was absent and should be deleted on cleanup. diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 0c29a0f5d..38a5741dd 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -231,6 +231,7 @@ export class Remote { applySshEnvironment( vscode.workspace.getConfiguration(), this.extensionContext.environmentVariableCollection, + this.serviceContainer.getSessionId(), ), ); // Create OAuth session manager for this remote deployment @@ -256,6 +257,7 @@ export class Remote { token, this.logger, this.serviceContainer.getTelemetryService(), + this.serviceContainer.getSessionId(), ); disposables.push(workspaceClient); diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index f89a0a316..9de5693af 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -595,6 +595,7 @@ export function createMockServiceContainer( return { getTelemetryService: () => telemetry, getLogger: () => logger, + getSessionId: () => "0123456789abcdef0123456789abcdef", getSecretsManager: () => require("secretsManager", overrides.secretsManager), getMementoManager: () => diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index c79b43869..57ec5e33d 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -148,6 +148,31 @@ describe("CoderApi", () => { ); }); + it("attaches the session ID to requests as a baggage header", async () => { + const sessionId = "0123456789abcdef0123456789abcdef"; + api = CoderApi.create( + CODER_URL, + AXIOS_TOKEN, + mockLogger, + NOOP_TELEMETRY_REPORTER, + sessionId, + ); + + const response = await api.getAxiosInstance().get("/api/v2/users/me"); + + expect(response.config.headers["baggage"]).toBe( + `session_id=${sessionId}`, + ); + }); + + it("omits the baggage header when no session ID is provided", async () => { + api = createApi(); + + const response = await api.getAxiosInstance().get("/api/v2/users/me"); + + expect(response.config.headers["baggage"]).toBeUndefined(); + }); + it("applies the default timeout to requests", async () => { api = createApi(); const response = await api.getAxiosInstance().get("/api/v2/users/me"); diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index 44bfbb798..00a9038fb 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -142,6 +142,7 @@ function createTestContext(telemetry?: TelemetryService) { authTelemetry, oauthCallback, "coder.coder-remote", + "0123456789abcdef0123456789abcdef", ); const mockSuccessfulAuth = (user = createMockUser()) => { diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 2f95f7ae8..b4c5fb6e0 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -68,6 +68,7 @@ function createTestContext() { base.oauthCallback, base.logger, EXTENSION_ID, + "0123456789abcdef0123456789abcdef", ); /** Starts login flow and waits for browser to open. Returns promise and state for completing flow. */ diff --git a/test/unit/remote/environment.test.ts b/test/unit/remote/environment.test.ts index 8476a8332..ae31304e3 100644 --- a/test/unit/remote/environment.test.ts +++ b/test/unit/remote/environment.test.ts @@ -14,6 +14,8 @@ import { } from "../../mocks/testHelpers"; const proxyEnv = { HTTP_PROXY: proxy, HTTPS_PROXY: proxy }; +const TEST_SESSION_ID = "0123456789abcdef0123456789abcdef"; +const sessionEnv = { CODER_TRACE_SESSION_ID: TEST_SESSION_ID }; type Environment = Record; beforeEach(() => { @@ -108,11 +110,16 @@ describe("applySshEnvironment", () => { it("applies proxy variables to process.env and the collection, and restores on dispose", () => { const env: Environment = {}; const collection = fakeEnvCollection(); - const expected = { ...proxyEnv, NO_PROXY: "internal.example.com" }; + const expected = { + ...proxyEnv, + NO_PROXY: "internal.example.com", + ...sessionEnv, + }; const applied = applySshEnvironment( config(withProxy({ "coder.proxyBypass": "internal.example.com" })), collection, + TEST_SESSION_ID, env, ); @@ -125,14 +132,14 @@ describe("applySshEnvironment", () => { expect(collection.vars).toEqual({}); }); - it("sets nothing when no proxy is configured", () => { + it("sets the session ID even when no proxy is configured", () => { const env: Environment = {}; const collection = fakeEnvCollection(); - applySshEnvironment(config(), collection, env); + applySshEnvironment(config(), collection, TEST_SESSION_ID, env); - expect(env).toEqual({}); - expect(collection.vars).toEqual({}); + expect(env).toEqual(sessionEnv); + expect(collection.vars).toEqual(sessionEnv); }); it("does not clear existing env proxy variables when proxy support is off", () => { @@ -146,11 +153,12 @@ describe("applySshEnvironment", () => { applySshEnvironment( config(withProxy({ "http.proxySupport": "off" })), collection, + TEST_SESSION_ID, env, ); - expect(env).toEqual(original); - expect(collection.vars).toEqual({}); + expect(env).toEqual({ ...original, ...sessionEnv }); + expect(collection.vars).toEqual(sessionEnv); }); it("does not overwrite existing lowercase variables", () => { @@ -163,10 +171,11 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), + TEST_SESSION_ID, env, ); - expect(env).toEqual({ ...original, ...proxyEnv }); + expect(env).toEqual({ ...original, ...proxyEnv, ...sessionEnv }); applied.dispose(); expect(env).toEqual(original); @@ -179,6 +188,7 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), + TEST_SESSION_ID, env, ); expect(env.HTTP_PROXY).toBe(proxy); @@ -193,6 +203,7 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), + TEST_SESSION_ID, ); try { From 849122a167f44fa46f47b4c315210576d94862b9 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 00:40:31 +0000 Subject: [PATCH 3/8] refactor(api): rename baggage key to client_session_id Align with the updated RFC: the session ID baggage key changes from session_id to client_session_id. --- src/api/coderApi.ts | 7 +++++-- test/unit/api/coderApi.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 15d3564c8..5931e6963 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -80,6 +80,9 @@ const coderSessionTokenHeader = "Coder-Session-Token"; /** W3C baggage header used to propagate the session ID to the server. */ const baggageHeader = "baggage"; +/** Baggage key that carries the client's session ID. */ +const sessionIdBaggageKey = "client_session_id"; + /** * Default timeout for REST requests, so requests hung on half-open TCP * connections (e.g. after system sleep) don't stall pollers forever. @@ -157,7 +160,7 @@ export class CoderApi extends Api implements vscode.Disposable { client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS; if (sessionId) { client.getAxiosInstance().defaults.headers.common[baggageHeader] = - `session_id=${sessionId}`; + `${sessionIdBaggageKey}=${sessionId}`; } client.setCredentials(baseUrl, token); @@ -392,7 +395,7 @@ export class CoderApi extends Api implements vscode.Disposable { const headers = { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...(this.sessionId - ? { [baggageHeader]: `session_id=${this.sessionId}` } + ? { [baggageHeader]: `${sessionIdBaggageKey}=${this.sessionId}` } : {}), ...configs.options?.headers, ...headersFromCommand, diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index 57ec5e33d..59b02b97a 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -161,7 +161,7 @@ describe("CoderApi", () => { const response = await api.getAxiosInstance().get("/api/v2/users/me"); expect(response.config.headers["baggage"]).toBe( - `session_id=${sessionId}`, + `client_session_id=${sessionId}`, ); }); From 26ba3341f4bb6c33cec86b6fc60e94d9832f8b8b Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 12 Aug 2026 17:43:08 -0700 Subject: [PATCH 4/8] docs: remove waffle --- src/api/coderApi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 5931e6963..33c9c5faa 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -80,7 +80,6 @@ const coderSessionTokenHeader = "Coder-Session-Token"; /** W3C baggage header used to propagate the session ID to the server. */ const baggageHeader = "baggage"; -/** Baggage key that carries the client's session ID. */ const sessionIdBaggageKey = "client_session_id"; /** From 5d327f2038bc9fa5de81991f2d6d5e77d3eb6cda Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 11:43:23 -0700 Subject: [PATCH 5/8] refactor: use constant case for session id baggage key --- src/api/coderApi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 33c9c5faa..ffb8b1b69 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -80,7 +80,7 @@ const coderSessionTokenHeader = "Coder-Session-Token"; /** W3C baggage header used to propagate the session ID to the server. */ const baggageHeader = "baggage"; -const sessionIdBaggageKey = "client_session_id"; +const SESSION_ID_BAGGAGE_KEY = "client_session_id"; /** * Default timeout for REST requests, so requests hung on half-open TCP @@ -159,7 +159,7 @@ export class CoderApi extends Api implements vscode.Disposable { client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS; if (sessionId) { client.getAxiosInstance().defaults.headers.common[baggageHeader] = - `${sessionIdBaggageKey}=${sessionId}`; + `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`; } client.setCredentials(baseUrl, token); @@ -394,7 +394,7 @@ export class CoderApi extends Api implements vscode.Disposable { const headers = { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...(this.sessionId - ? { [baggageHeader]: `${sessionIdBaggageKey}=${this.sessionId}` } + ? { [baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${this.sessionId}` } : {}), ...configs.options?.headers, ...headersFromCommand, From 1acc53b7af569fb29b1eb478a5bfe62e2451bd84 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 17:29:03 -0700 Subject: [PATCH 6/8] feat: make baggage header non-overridable --- src/api/coderApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index ffb8b1b69..082c01374 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -393,11 +393,11 @@ export class CoderApi extends Api implements vscode.Disposable { */ const headers = { ...(token ? { [coderSessionTokenHeader]: token } : {}), + ...configs.options?.headers, + ...headersFromCommand, ...(this.sessionId ? { [baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${this.sessionId}` } : {}), - ...configs.options?.headers, - ...headersFromCommand, }; const baseUrl = new URL(baseUrlRaw); From f625b5075892004af0d35ba952724b2ed884b7b4 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 14 Aug 2026 00:45:11 +0000 Subject: [PATCH 7/8] refactor(core): expose session ID as a module constant Add core/sessionId.ts exporting a single session ID constant (generated once per activation) and remove newSessionId() from telemetry/ids.ts. Consumers now import the constant directly instead of threading it through constructors and CoderApi.create, so every CoderApi client attaches the client_session_id baggage header unconditionally. Addresses review feedback on #1074. --- src/api/coderApi.ts | 20 +++++++------------- src/core/container.ts | 15 +++------------ src/core/sessionId.ts | 11 +++++++++++ src/deployment/deploymentManager.ts | 10 +--------- src/extension.ts | 1 - src/login/loginCoordinator.ts | 18 ++---------------- src/oauth/authorizer.ts | 9 +-------- src/oauth/sessionManager.ts | 18 ++---------------- src/remote/environment.ts | 2 +- src/remote/remote.ts | 2 -- src/telemetry/ids.ts | 6 ------ test/mocks/testHelpers.ts | 1 - test/unit/api/coderApi.test.ts | 24 +++++++----------------- test/unit/login/loginCoordinator.test.ts | 1 - test/unit/oauth/authorizer.test.ts | 1 - test/unit/remote/environment.test.ts | 11 +++-------- 16 files changed, 38 insertions(+), 112 deletions(-) create mode 100644 src/core/sessionId.ts diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 082c01374..a1853feeb 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -12,6 +12,7 @@ import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, } from "../configWatcher"; +import { sessionId } from "../core/sessionId"; import { ClientCertificateError } from "../error/clientCertificateError"; import { toError } from "../error/errorUtils"; import { ServerCertificateError } from "../error/serverCertificateError"; @@ -124,7 +125,6 @@ export class CoderApi extends Api implements vscode.Disposable { private readonly telemetry: TelemetryReporter, private readonly httpRequestsTelemetry: HttpRequestsTelemetry, private readonly authConfigTracker: AuthConfigTracker, - private readonly sessionId: string | undefined, ) { super(); wrapWithValidation(this); @@ -136,16 +136,15 @@ export class CoderApi extends Api implements vscode.Disposable { * Automatically sets up logging interceptors, certificate handling, * HTTP request telemetry, and WebSocket connection telemetry. All * telemetry routes through the single reporter passed in (defaults to - * NOOP_TELEMETRY_REPORTER for throwaway clients). When a session ID is - * provided it is attached to every request via the `baggage` header so the - * server can correlate requests with the session's logs and telemetry. + * NOOP_TELEMETRY_REPORTER for throwaway clients). The session ID is + * attached to every request via the `baggage` header so the server can + * correlate requests with the session's logs and telemetry. */ static create( baseUrl: string, token: string | undefined, output: Logger, telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER, - sessionId?: string, ): CoderApi { const httpRequestsTelemetry = new HttpRequestsTelemetry(telemetry); const authConfigTracker = new AuthConfigTracker(); @@ -154,13 +153,10 @@ export class CoderApi extends Api implements vscode.Disposable { telemetry, httpRequestsTelemetry, authConfigTracker, - sessionId, ); client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS; - if (sessionId) { - client.getAxiosInstance().defaults.headers.common[baggageHeader] = - `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`; - } + client.getAxiosInstance().defaults.headers.common[baggageHeader] = + `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`; client.setCredentials(baseUrl, token); setupInterceptors(client, output, httpRequestsTelemetry, authConfigTracker); @@ -395,9 +391,7 @@ export class CoderApi extends Api implements vscode.Disposable { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...configs.options?.headers, ...headersFromCommand, - ...(this.sessionId - ? { [baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${this.sessionId}` } - : {}), + [baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`, }; const baseUrl = new URL(baseUrlRaw); diff --git a/src/core/container.ts b/src/core/container.ts index 431b807bc..76d3ca362 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -5,7 +5,6 @@ import { prefixLogger } from "../logging/prefixLogger"; import { LoginCoordinator } from "../login/loginCoordinator"; import { OAuthCallback } from "../oauth/oauthCallback"; import { buildSession, extractExtensionVersion } from "../telemetry/event"; -import { newSessionId } from "../telemetry/ids"; import { TelemetryService } from "../telemetry/service"; import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink"; import { NetcheckPanelFactory } from "../webviews/netcheck/netcheckPanelFactory"; @@ -19,6 +18,7 @@ import { ContextManager } from "./contextManager"; import { MementoManager } from "./mementoManager"; import { PathResolver } from "./pathResolver"; import { SecretsManager } from "./secretsManager"; +import { sessionId } from "./sessionId"; import type { Logger } from "../logging/logger"; @@ -28,7 +28,6 @@ import type { Logger } from "../logging/logger"; */ export class ServiceContainer implements vscode.Disposable { private readonly outputChannel: vscode.LogOutputChannel; - private readonly sessionId: string; private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; @@ -48,10 +47,7 @@ export class ServiceContainer implements vscode.Disposable { 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 = prefixLogger(this.outputChannel, `[${this.sessionId}]`); + this.logger = prefixLogger(this.outputChannel, `[${sessionId}]`); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, context.logUri.fsPath, @@ -65,7 +61,7 @@ export class ServiceContainer implements vscode.Disposable { const session = buildSession( extractExtensionVersion(context.extension.packageJSON), - this.sessionId, + sessionId, ); const localJsonlSink = LocalJsonlSink.start( { @@ -114,7 +110,6 @@ export class ServiceContainer implements vscode.Disposable { new AuthTelemetry(this.telemetryService), this.oauthCallback, context.extension.id, - this.sessionId, ); this.duplicateWorkspaceIpc = new DuplicateWorkspaceIpc( context.secrets, @@ -149,10 +144,6 @@ export class ServiceContainer implements vscode.Disposable { return this.logger; } - getSessionId(): string { - return this.sessionId; - } - getCliManager(): CliManager { return this.cliManager; } diff --git a/src/core/sessionId.ts b/src/core/sessionId.ts new file mode 100644 index 000000000..5a131b327 --- /dev/null +++ b/src/core/sessionId.ts @@ -0,0 +1,11 @@ +import { randomBytes } from "node:crypto"; + +/** + * One session ID per activation, shared by logs, API requests, telemetry, and + * the CLI so all data for a session can be correlated by a single ID. + * + * 16 bytes / 32 lowercase hex, matching the OTel id format so a future OTel + * exporter maps 1:1. Avoids `vscode.env.sessionId`, which is a UUID + * concatenated with a timestamp. + */ +export const sessionId = randomBytes(16).toString("hex"); diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index a17f13722..7c0c47e3e 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -49,7 +49,6 @@ export class DeploymentManager implements vscode.Disposable { private readonly logger: Logger; private readonly telemetryService: TelemetryService; private readonly deploymentTelemetry: DeploymentTelemetry; - private readonly sessionId: string; readonly #sessionStore = new SessionStore(); #disposed = false; @@ -70,7 +69,6 @@ export class DeploymentManager implements vscode.Disposable { this.logger = serviceContainer.getLogger(); this.telemetryService = serviceContainer.getTelemetryService(); this.deploymentTelemetry = new DeploymentTelemetry(this.telemetryService); - this.sessionId = serviceContainer.getSessionId(); } public static create( @@ -143,13 +141,7 @@ export class DeploymentManager implements vscode.Disposable { url: string, token: string | undefined, ): Promise { - const tempClient = CoderApi.create( - url, - token, - this.logger, - undefined, - this.sessionId, - ); + const tempClient = CoderApi.create(url, token, this.logger); try { return await tempClient.getAuthenticatedUser(); } finally { diff --git a/src/extension.ts b/src/extension.ts index dae226b28..6dc82209e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -141,7 +141,6 @@ async function doActivate( deploymentSessionAuth?.token, output, telemetryService, - serviceContainer.getSessionId(), ); ctx.subscriptions.push(client); diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 457e868ab..15c4fd93f 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -91,14 +91,12 @@ export class LoginCoordinator implements vscode.Disposable { private readonly authTelemetry: AuthTelemetry, oauthCallback: OAuthCallback, extensionId: string, - private readonly sessionId: string, ) { this.oauthAuthorizer = new OAuthAuthorizer( secretsManager, oauthCallback, logger, extensionId, - sessionId, ); } @@ -250,13 +248,7 @@ export class LoginCoordinator implements vscode.Disposable { safeHostname, async (auth) => { if (auth?.token) { - const client = CoderApi.create( - auth.url, - auth.token, - this.logger, - undefined, - this.sessionId, - ); + const client = CoderApi.create(auth.url, auth.token, this.logger); try { const user = await client.getAuthenticatedUser(); // Stop listening only on success; a bad token shouldn't @@ -292,13 +284,7 @@ export class LoginCoordinator implements vscode.Disposable { providedToken?: string, tokenSignInConfirmed = false, ): Promise { - const client = CoderApi.create( - deployment.url, - "", - this.logger, - undefined, - this.sessionId, - ); + const client = CoderApi.create(deployment.url, "", this.logger); try { return await this.runLoginAttempts( client, diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 0420bbba4..b22bb90a7 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -51,7 +51,6 @@ export class OAuthAuthorizer implements vscode.Disposable { private readonly oauthCallback: OAuthCallback, private readonly logger: Logger, private readonly extensionId: string, - private readonly sessionId: string, ) {} /** @@ -64,13 +63,7 @@ export class OAuthAuthorizer implements vscode.Disposable { progress: vscode.Progress<{ message?: string; increment?: number }>, cancellationToken: vscode.CancellationToken, ): Promise<{ tokenResponse: OAuth2TokenResponse; user: User }> { - const client = CoderApi.create( - deployment.url, - undefined, - this.logger, - undefined, - this.sessionId, - ); + const client = CoderApi.create(deployment.url, undefined, this.logger); try { return await this.runLoginFlow( client, diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index baacf2b09..5ccc122aa 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -70,7 +70,6 @@ export class OAuthSessionManager implements vscode.Disposable { container.getLogger(), onAuthRequired, new AuthTelemetry(container.getTelemetryService()), - container.getSessionId(), ); manager.setupTokenListener(); manager.scheduleNextRefresh(); @@ -83,7 +82,6 @@ export class OAuthSessionManager implements vscode.Disposable { private readonly logger: Logger, private readonly onAuthRequired: () => Promise, private readonly authTelemetry: AuthTelemetry, - private readonly sessionId: string, ) {} /** @@ -301,13 +299,7 @@ export class OAuthSessionManager implements vscode.Disposable { }) => Promise, ): Promise { const deployment = this.requireDeployment(); - const client = CoderApi.create( - deployment.url, - token, - this.logger, - undefined, - this.sessionId, - ); + const client = CoderApi.create(deployment.url, token, this.logger); try { const axiosInstance = client.getAxiosInstance(); const metadataClient = new OAuthMetadataClient( @@ -467,13 +459,7 @@ export class OAuthSessionManager implements vscode.Disposable { deployment: Deployment, accessToken: string, ): Promise { - const client = CoderApi.create( - deployment.url, - accessToken, - this.logger, - undefined, - this.sessionId, - ); + const client = CoderApi.create(deployment.url, accessToken, this.logger); try { return (await client.getAuthenticatedUser()).username; } catch (error) { diff --git a/src/remote/environment.ts b/src/remote/environment.ts index 4c913c13f..29a0d4c8d 100644 --- a/src/remote/environment.ts +++ b/src/remote/environment.ts @@ -1,4 +1,5 @@ import { joinNoProxy } from "../api/proxy"; +import { sessionId } from "../core/sessionId"; import type { GlobalEnvironmentVariableCollection, @@ -41,7 +42,6 @@ export function applySshEnvironment( GlobalEnvironmentVariableCollection, "persistent" | "replace" | "clear" >, - sessionId: string, env: Environment = process.env, ): { dispose(): void } { const values: Environment = { diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 38a5741dd..0c29a0f5d 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -231,7 +231,6 @@ export class Remote { applySshEnvironment( vscode.workspace.getConfiguration(), this.extensionContext.environmentVariableCollection, - this.serviceContainer.getSessionId(), ), ); // Create OAuth session manager for this remote deployment @@ -257,7 +256,6 @@ export class Remote { token, this.logger, this.serviceContainer.getTelemetryService(), - this.serviceContainer.getSessionId(), ); disposables.push(workspaceClient); diff --git a/src/telemetry/ids.ts b/src/telemetry/ids.ts index 7de0be12e..486eeb61f 100644 --- a/src/telemetry/ids.ts +++ b/src/telemetry/ids.ts @@ -12,9 +12,3 @@ export function newTraceId(): string { export function newSpanId(): string { return randomBytes(8).toString("hex"); } - -/** Our own session id (16 bytes / 32 hex). Avoids `vscode.env.sessionId`, - * which is a UUID concatenated with a timestamp. */ -export function newSessionId(): string { - return randomBytes(16).toString("hex"); -} diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 9de5693af..f89a0a316 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -595,7 +595,6 @@ export function createMockServiceContainer( return { getTelemetryService: () => telemetry, getLogger: () => logger, - getSessionId: () => "0123456789abcdef0123456789abcdef", getSecretsManager: () => require("secretsManager", overrides.secretsManager), getMementoManager: () => diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index 59b02b97a..b3eca3fc4 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -29,6 +29,7 @@ import { } from "@/api/responseValidation"; import { createHttpAgent } from "@/api/utils"; import { CONFIG_CHANGE_DEBOUNCE_MS } from "@/configWatcher"; +import { sessionId } from "@/core/sessionId"; import { ClientCertificateError } from "@/error/clientCertificateError"; import { ServerCertificateError } from "@/error/serverCertificateError"; import { getHeaders } from "@/headers"; @@ -148,15 +149,8 @@ describe("CoderApi", () => { ); }); - it("attaches the session ID to requests as a baggage header", async () => { - const sessionId = "0123456789abcdef0123456789abcdef"; - api = CoderApi.create( - CODER_URL, - AXIOS_TOKEN, - mockLogger, - NOOP_TELEMETRY_REPORTER, - sessionId, - ); + it("attaches the session ID to every request as a baggage header", async () => { + api = createApi(); const response = await api.getAxiosInstance().get("/api/v2/users/me"); @@ -165,14 +159,6 @@ describe("CoderApi", () => { ); }); - it("omits the baggage header when no session ID is provided", async () => { - api = createApi(); - - const response = await api.getAxiosInstance().get("/api/v2/users/me"); - - expect(response.config.headers["baggage"]).toBeUndefined(); - }); - it("applies the default timeout to requests", async () => { api = createApi(); const response = await api.getAxiosInstance().get("/api/v2/users/me"); @@ -498,6 +484,7 @@ describe("CoderApi", () => { headers: { "X-Custom-Header": "custom-value", "Coder-Session-Token": AXIOS_TOKEN, + baggage: `client_session_id=${sessionId}`, }, }); }); @@ -511,6 +498,7 @@ describe("CoderApi", () => { followRedirects: true, headers: { "Coder-Session-Token": AXIOS_TOKEN, + baggage: `client_session_id=${sessionId}`, }, }); @@ -528,6 +516,7 @@ describe("CoderApi", () => { headers: { "Coder-Session-Token": "from-config", "X-Config-Header": "config-value", + baggage: `client_session_id=${sessionId}`, }, }); @@ -547,6 +536,7 @@ describe("CoderApi", () => { followRedirects: true, headers: { "Coder-Session-Token": "from-header-command", + baggage: `client_session_id=${sessionId}`, }, }); }); diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index 00a9038fb..44bfbb798 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -142,7 +142,6 @@ function createTestContext(telemetry?: TelemetryService) { authTelemetry, oauthCallback, "coder.coder-remote", - "0123456789abcdef0123456789abcdef", ); const mockSuccessfulAuth = (user = createMockUser()) => { diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index b4c5fb6e0..2f95f7ae8 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -68,7 +68,6 @@ function createTestContext() { base.oauthCallback, base.logger, EXTENSION_ID, - "0123456789abcdef0123456789abcdef", ); /** Starts login flow and waits for browser to open. Returns promise and state for completing flow. */ diff --git a/test/unit/remote/environment.test.ts b/test/unit/remote/environment.test.ts index ae31304e3..c616e92f6 100644 --- a/test/unit/remote/environment.test.ts +++ b/test/unit/remote/environment.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionId } from "@/core/sessionId"; import { applySshEnvironment, getSshProxyEnvironment, @@ -14,8 +15,7 @@ import { } from "../../mocks/testHelpers"; const proxyEnv = { HTTP_PROXY: proxy, HTTPS_PROXY: proxy }; -const TEST_SESSION_ID = "0123456789abcdef0123456789abcdef"; -const sessionEnv = { CODER_TRACE_SESSION_ID: TEST_SESSION_ID }; +const sessionEnv = { CODER_TRACE_SESSION_ID: sessionId }; type Environment = Record; beforeEach(() => { @@ -119,7 +119,6 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy({ "coder.proxyBypass": "internal.example.com" })), collection, - TEST_SESSION_ID, env, ); @@ -136,7 +135,7 @@ describe("applySshEnvironment", () => { const env: Environment = {}; const collection = fakeEnvCollection(); - applySshEnvironment(config(), collection, TEST_SESSION_ID, env); + applySshEnvironment(config(), collection, env); expect(env).toEqual(sessionEnv); expect(collection.vars).toEqual(sessionEnv); @@ -153,7 +152,6 @@ describe("applySshEnvironment", () => { applySshEnvironment( config(withProxy({ "http.proxySupport": "off" })), collection, - TEST_SESSION_ID, env, ); @@ -171,7 +169,6 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), - TEST_SESSION_ID, env, ); @@ -188,7 +185,6 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), - TEST_SESSION_ID, env, ); expect(env.HTTP_PROXY).toBe(proxy); @@ -203,7 +199,6 @@ describe("applySshEnvironment", () => { const applied = applySshEnvironment( config(withProxy()), fakeEnvCollection(), - TEST_SESSION_ID, ); try { From fec1170b25d3b9cdfad5b0d1ebb6e692d141ea19 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 17:59:38 -0700 Subject: [PATCH 8/8] refactor: rename SshEnvironment to SshProxyEnvironment --- src/remote/environment.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/remote/environment.ts b/src/remote/environment.ts index 29a0d4c8d..15bf1f40f 100644 --- a/src/remote/environment.ts +++ b/src/remote/environment.ts @@ -7,7 +7,7 @@ import type { } from "vscode"; type Environment = Record; -type SshEnvironment = Partial< +type SshProxyEnvironment = Partial< Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string> >; @@ -70,7 +70,7 @@ export function applySshEnvironment( /** The proxy portion of the SSH environment, derived from VS Code's settings. */ export function getSshProxyEnvironment( cfg: Pick, -): SshEnvironment { +): SshProxyEnvironment { if (cfg.get("http.proxySupport") === "off") { return {}; }