diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 7b02b9b24b..a1853feeb9 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"; @@ -77,6 +78,11 @@ import type { const coderSessionTokenHeader = "Coder-Session-Token"; +/** W3C baggage header used to propagate the session ID to the server. */ +const baggageHeader = "baggage"; + +const SESSION_ID_BAGGAGE_KEY = "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. @@ -130,7 +136,9 @@ 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). 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, @@ -147,6 +155,8 @@ export class CoderApi extends Api implements vscode.Disposable { authConfigTracker, ); client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS; + client.getAxiosInstance().defaults.headers.common[baggageHeader] = + `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`; client.setCredentials(baseUrl, token); setupInterceptors(client, output, httpRequestsTelemetry, authConfigTracker); @@ -381,6 +391,7 @@ export class CoderApi extends Api implements vscode.Disposable { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...configs.options?.headers, ...headersFromCommand, + [baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`, }; const baseUrl = new URL(baseUrlRaw); diff --git a/src/core/container.ts b/src/core/container.ts index 9c3f484405..76d3ca3624 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( { diff --git a/src/core/sessionId.ts b/src/core/sessionId.ts new file mode 100644 index 0000000000..5a131b3273 --- /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/remote/environment.ts b/src/remote/environment.ts index ab9e35c84f..15bf1f40fe 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, @@ -6,7 +7,7 @@ import type { } from "vscode"; type Environment = Record; -type SshEnvironment = Partial< +type SshProxyEnvironment = Partial< Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string> >; @@ -26,13 +27,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, @@ -42,7 +44,10 @@ export function applySshEnvironment( >, 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; @@ -65,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 {}; } @@ -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/telemetry/ids.ts b/src/telemetry/ids.ts index 7de0be12e5..486eeb61fa 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/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index c79b438699..b3eca3fc48 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,6 +149,16 @@ describe("CoderApi", () => { ); }); + 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"); + + expect(response.config.headers["baggage"]).toBe( + `client_session_id=${sessionId}`, + ); + }); + it("applies the default timeout to requests", async () => { api = createApi(); const response = await api.getAxiosInstance().get("/api/v2/users/me"); @@ -473,6 +484,7 @@ describe("CoderApi", () => { headers: { "X-Custom-Header": "custom-value", "Coder-Session-Token": AXIOS_TOKEN, + baggage: `client_session_id=${sessionId}`, }, }); }); @@ -486,6 +498,7 @@ describe("CoderApi", () => { followRedirects: true, headers: { "Coder-Session-Token": AXIOS_TOKEN, + baggage: `client_session_id=${sessionId}`, }, }); @@ -503,6 +516,7 @@ describe("CoderApi", () => { headers: { "Coder-Session-Token": "from-config", "X-Config-Header": "config-value", + baggage: `client_session_id=${sessionId}`, }, }); @@ -522,6 +536,7 @@ describe("CoderApi", () => { followRedirects: true, headers: { "Coder-Session-Token": "from-header-command", + baggage: `client_session_id=${sessionId}`, }, }); }); diff --git a/test/unit/remote/environment.test.ts b/test/unit/remote/environment.test.ts index 8476a8332b..c616e92f62 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,6 +15,7 @@ import { } from "../../mocks/testHelpers"; const proxyEnv = { HTTP_PROXY: proxy, HTTPS_PROXY: proxy }; +const sessionEnv = { CODER_TRACE_SESSION_ID: sessionId }; type Environment = Record; beforeEach(() => { @@ -108,7 +110,11 @@ 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" })), @@ -125,14 +131,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); - 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", () => { @@ -149,8 +155,8 @@ describe("applySshEnvironment", () => { 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", () => { @@ -166,7 +172,7 @@ describe("applySshEnvironment", () => { env, ); - expect(env).toEqual({ ...original, ...proxyEnv }); + expect(env).toEqual({ ...original, ...proxyEnv, ...sessionEnv }); applied.dispose(); expect(env).toEqual(original);