Skip to content

Commit b42cbbf

Browse files
authored
feat: attach client_session_id to requests and set the CLI session env var (#1074)
## What Propagate the session ID from Phase 1 to outbound API requests and to the CLI, so server-side logs, telemetry, and the spawned `coder ssh` process can all be correlated with the extension's session. This is **Phase 2 of 3** for [DEVEX-661](https://linear.app/codercom/issue/DEVEX-661). It builds on #1073. ## Changes - **Requests (RFC req 3):** add a `sessionId` argument to `CoderApi.create` and attach the session ID to every request via the W3C `baggage` header using the `client_session_id` key (`baggage: client_session_id=<hex>`), on both the REST default headers and the WebSocket handshake headers. Threaded through all `CoderApi.create` call sites, including the pre-auth OAuth/login/deployment clients, so every request carries it. - **Telemetry (RFC req 4.2):** already satisfied — the shared ID is the telemetry `sessionId`, which ships on every event's context. - **CLI (RFC reqs 5.2/5.3):** extend `applySshEnvironment` to also 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. ## Testing - `pnpm typecheck`, targeted `pnpm lint`, full `pnpm test:extension` (2108 passing). - New tests: `baggage: client_session_id=<hex>` present/absent on `CoderApi`; `CODER_TRACE_SESSION_ID` applied to `process.env` and the terminal collection and restored on dispose. --- 🤖 Generated by Coder Agents.
1 parent 5fbc409 commit b42cbbf

7 files changed

Lines changed: 75 additions & 32 deletions

File tree

src/api/coderApi.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CONFIG_CHANGE_DEBOUNCE_MS,
1313
watchConfigurationChanges,
1414
} from "../configWatcher";
15+
import { sessionId } from "../core/sessionId";
1516
import { ClientCertificateError } from "../error/clientCertificateError";
1617
import { toError } from "../error/errorUtils";
1718
import { ServerCertificateError } from "../error/serverCertificateError";
@@ -77,6 +78,11 @@ import type {
7778

7879
const coderSessionTokenHeader = "Coder-Session-Token";
7980

81+
/** W3C baggage header used to propagate the session ID to the server. */
82+
const BAGGAGE_HEADER = "baggage";
83+
const SESSION_ID_BAGGAGE_KEY = "client_session_id";
84+
const SESSION_ID_BAGGAGE = `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`;
85+
8086
/**
8187
* Default timeout for REST requests, so requests hung on half-open TCP
8288
* connections (e.g. after system sleep) don't stall pollers forever.
@@ -130,7 +136,9 @@ export class CoderApi extends Api implements vscode.Disposable {
130136
* Automatically sets up logging interceptors, certificate handling,
131137
* HTTP request telemetry, and WebSocket connection telemetry. All
132138
* telemetry routes through the single reporter passed in (defaults to
133-
* NOOP_TELEMETRY_REPORTER for throwaway clients).
139+
* NOOP_TELEMETRY_REPORTER for throwaway clients). The session ID is
140+
* attached to every request via the `baggage` header so the server can
141+
* correlate requests with the session's logs and telemetry.
134142
*/
135143
static create(
136144
baseUrl: string,
@@ -147,6 +155,8 @@ export class CoderApi extends Api implements vscode.Disposable {
147155
authConfigTracker,
148156
);
149157
client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS;
158+
client.getAxiosInstance().defaults.headers.common[BAGGAGE_HEADER] =
159+
SESSION_ID_BAGGAGE;
150160
client.setCredentials(baseUrl, token);
151161

152162
setupInterceptors(client, output, httpRequestsTelemetry, authConfigTracker);
@@ -381,6 +391,7 @@ export class CoderApi extends Api implements vscode.Disposable {
381391
...(token ? { [coderSessionTokenHeader]: token } : {}),
382392
...configs.options?.headers,
383393
...headersFromCommand,
394+
[BAGGAGE_HEADER]: SESSION_ID_BAGGAGE,
384395
};
385396

386397
const baseUrl = new URL(baseUrlRaw);

src/core/container.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { shortId } from "../logging/utils";
66
import { LoginCoordinator } from "../login/loginCoordinator";
77
import { OAuthCallback } from "../oauth/oauthCallback";
88
import { buildSession, extractExtensionVersion } from "../telemetry/event";
9-
import { newSessionId } from "../telemetry/ids";
109
import { TelemetryService } from "../telemetry/service";
1110
import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink";
1211
import { NetcheckPanelFactory } from "../webviews/netcheck/netcheckPanelFactory";
@@ -20,6 +19,7 @@ import { ContextManager } from "./contextManager";
2019
import { MementoManager } from "./mementoManager";
2120
import { PathResolver } from "./pathResolver";
2221
import { SecretsManager } from "./secretsManager";
22+
import { sessionId } from "./sessionId";
2323

2424
import type { Logger } from "../logging/logger";
2525

@@ -29,7 +29,6 @@ import type { Logger } from "../logging/logger";
2929
*/
3030
export class ServiceContainer implements vscode.Disposable {
3131
private readonly outputChannel: vscode.LogOutputChannel;
32-
private readonly sessionId: string;
3332
private readonly logger: Logger;
3433
private readonly pathResolver: PathResolver;
3534
private readonly mementoManager: MementoManager;
@@ -49,12 +48,9 @@ export class ServiceContainer implements vscode.Disposable {
4948
this.outputChannel = vscode.window.createOutputChannel("Coder", {
5049
log: true,
5150
});
52-
// One session ID per activation, shared by logs, API requests,
53-
// telemetry, and the CLI so all data for a session correlates.
54-
this.sessionId = newSessionId();
5551
this.logger = prefixLogger(
5652
this.outputChannel,
57-
`[session ${shortId(this.sessionId)}]`,
53+
`[session ${shortId(sessionId)}]`,
5854
);
5955
this.pathResolver = new PathResolver(
6056
context.globalStorageUri.fsPath,
@@ -69,7 +65,7 @@ export class ServiceContainer implements vscode.Disposable {
6965

7066
const session = buildSession(
7167
extractExtensionVersion(context.extension.packageJSON),
72-
this.sessionId,
68+
sessionId,
7369
);
7470
const localJsonlSink = LocalJsonlSink.start(
7571
{

src/core/sessionId.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
/**
4+
* One session ID per extension host process, shared across logs, API
5+
* requests, telemetry, and the CLI so all data for a session can be correlated
6+
* by a single ID.
7+
*
8+
* In rare cases when the extension is deactivated then activated within the
9+
* same window, it should reuse the same ID. However, reloading or closing-
10+
* then-reopening a window creates a new process with a new ID.
11+
*
12+
* 16 bytes / 32 lowercase hex, matching the OTel id format so a future OTel
13+
* exporter maps 1:1. Avoids `vscode.env.sessionId`, which is a UUID
14+
* concatenated with a timestamp.
15+
*/
16+
export const sessionId = randomBytes(16).toString("hex");

src/remote/environment.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { joinNoProxy } from "../api/proxy";
2+
import { sessionId } from "../core/sessionId";
23

34
import type {
45
GlobalEnvironmentVariableCollection,
56
WorkspaceConfiguration,
67
} from "vscode";
78

89
type Environment = Record<string, string | undefined>;
9-
type SshEnvironment = Partial<
10+
type SshProxyEnvironment = Partial<
1011
Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string>
1112
>;
1213

@@ -26,13 +27,14 @@ export const SSH_PROXY_SETTINGS: ReadonlyArray<{
2627

2728
/**
2829
* Apply the SSH environment that the spawned `coder ssh` ProxyCommand inherits.
29-
* Currently just the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the
30-
* coder CLI like any Go HTTP client. Applied via both process.env (ssh spawned as
31-
* a child, `remote.SSH.useLocalServer=true`) and the terminal env collection (ssh
32-
* spawned in a terminal, `useLocalServer=false`, which can't see process.env),
33-
* since the mode isn't knowable up front. Mutating env rather than the SSH config
34-
* keeps credentialed URLs off disk and windows independent. Disposable restores
35-
* both.
30+
* Includes the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the coder
31+
* CLI like any Go HTTP client, and the session ID via CODER_TRACE_SESSION_ID so
32+
* the CLI reuses the plugin's session ID instead of generating its own. Applied
33+
* via both process.env (ssh spawned as a child, `remote.SSH.useLocalServer=true`)
34+
* and the terminal env collection (ssh spawned in a terminal,
35+
* `useLocalServer=false`, which can't see process.env), since the mode isn't
36+
* knowable up front. Mutating env rather than the SSH config keeps credentialed
37+
* URLs off disk and windows independent. Disposable restores both.
3638
*/
3739
export function applySshEnvironment(
3840
cfg: Pick<WorkspaceConfiguration, "get">,
@@ -42,7 +44,10 @@ export function applySshEnvironment(
4244
>,
4345
env: Environment = process.env,
4446
): { dispose(): void } {
45-
const values = getSshProxyEnvironment(cfg);
47+
const values: Environment = {
48+
...getSshProxyEnvironment(cfg),
49+
CODER_TRACE_SESSION_ID: sessionId,
50+
};
4651
const restoreEnv = applyEnvironment(values, env);
4752

4853
collection.persistent = false;
@@ -65,7 +70,7 @@ export function applySshEnvironment(
6570
/** The proxy portion of the SSH environment, derived from VS Code's settings. */
6671
export function getSshProxyEnvironment(
6772
cfg: Pick<WorkspaceConfiguration, "get">,
68-
): SshEnvironment {
73+
): SshProxyEnvironment {
6974
if (cfg.get<string>("http.proxySupport") === "off") {
7075
return {};
7176
}
@@ -83,7 +88,7 @@ export function getSshProxyEnvironment(
8388
}
8489

8590
function applyEnvironment(
86-
values: SshEnvironment,
91+
values: Environment,
8792
env: Environment,
8893
): { dispose(): void } {
8994
// Stored `undefined` means the key was absent and should be deleted on cleanup.

src/telemetry/ids.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,3 @@ export function newTraceId(): string {
1212
export function newSpanId(): string {
1313
return randomBytes(8).toString("hex");
1414
}
15-
16-
/** Our own session id (16 bytes / 32 hex). Avoids `vscode.env.sessionId`,
17-
* which is a UUID concatenated with a timestamp. */
18-
export function newSessionId(): string {
19-
return randomBytes(16).toString("hex");
20-
}

test/unit/api/coderApi.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from "@/api/responseValidation";
3030
import { createHttpAgent } from "@/api/utils";
3131
import { CONFIG_CHANGE_DEBOUNCE_MS } from "@/configWatcher";
32+
import { sessionId } from "@/core/sessionId";
3233
import { ClientCertificateError } from "@/error/clientCertificateError";
3334
import { ServerCertificateError } from "@/error/serverCertificateError";
3435
import { getHeaders } from "@/headers";
@@ -148,6 +149,16 @@ describe("CoderApi", () => {
148149
);
149150
});
150151

152+
it("attaches the session ID to every request as a baggage header", async () => {
153+
api = createApi();
154+
155+
const response = await api.getAxiosInstance().get("/api/v2/users/me");
156+
157+
expect(response.config.headers["baggage"]).toBe(
158+
`client_session_id=${sessionId}`,
159+
);
160+
});
161+
151162
it("applies the default timeout to requests", async () => {
152163
api = createApi();
153164
const response = await api.getAxiosInstance().get("/api/v2/users/me");
@@ -473,6 +484,7 @@ describe("CoderApi", () => {
473484
headers: {
474485
"X-Custom-Header": "custom-value",
475486
"Coder-Session-Token": AXIOS_TOKEN,
487+
baggage: `client_session_id=${sessionId}`,
476488
},
477489
});
478490
});
@@ -486,6 +498,7 @@ describe("CoderApi", () => {
486498
followRedirects: true,
487499
headers: {
488500
"Coder-Session-Token": AXIOS_TOKEN,
501+
baggage: `client_session_id=${sessionId}`,
489502
},
490503
});
491504

@@ -503,6 +516,7 @@ describe("CoderApi", () => {
503516
headers: {
504517
"Coder-Session-Token": "from-config",
505518
"X-Config-Header": "config-value",
519+
baggage: `client_session_id=${sessionId}`,
506520
},
507521
});
508522

@@ -522,6 +536,7 @@ describe("CoderApi", () => {
522536
followRedirects: true,
523537
headers: {
524538
"Coder-Session-Token": "from-header-command",
539+
baggage: `client_session_id=${sessionId}`,
525540
},
526541
});
527542
});

test/unit/remote/environment.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { spawnSync } from "node:child_process";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

4+
import { sessionId } from "@/core/sessionId";
45
import {
56
applySshEnvironment,
67
getSshProxyEnvironment,
@@ -14,6 +15,7 @@ import {
1415
} from "../../mocks/testHelpers";
1516

1617
const proxyEnv = { HTTP_PROXY: proxy, HTTPS_PROXY: proxy };
18+
const sessionEnv = { CODER_TRACE_SESSION_ID: sessionId };
1719
type Environment = Record<string, string | undefined>;
1820

1921
beforeEach(() => {
@@ -108,7 +110,11 @@ describe("applySshEnvironment", () => {
108110
it("applies proxy variables to process.env and the collection, and restores on dispose", () => {
109111
const env: Environment = {};
110112
const collection = fakeEnvCollection();
111-
const expected = { ...proxyEnv, NO_PROXY: "internal.example.com" };
113+
const expected = {
114+
...proxyEnv,
115+
NO_PROXY: "internal.example.com",
116+
...sessionEnv,
117+
};
112118

113119
const applied = applySshEnvironment(
114120
config(withProxy({ "coder.proxyBypass": "internal.example.com" })),
@@ -125,14 +131,14 @@ describe("applySshEnvironment", () => {
125131
expect(collection.vars).toEqual({});
126132
});
127133

128-
it("sets nothing when no proxy is configured", () => {
134+
it("sets the session ID even when no proxy is configured", () => {
129135
const env: Environment = {};
130136
const collection = fakeEnvCollection();
131137

132138
applySshEnvironment(config(), collection, env);
133139

134-
expect(env).toEqual({});
135-
expect(collection.vars).toEqual({});
140+
expect(env).toEqual(sessionEnv);
141+
expect(collection.vars).toEqual(sessionEnv);
136142
});
137143

138144
it("does not clear existing env proxy variables when proxy support is off", () => {
@@ -149,8 +155,8 @@ describe("applySshEnvironment", () => {
149155
env,
150156
);
151157

152-
expect(env).toEqual(original);
153-
expect(collection.vars).toEqual({});
158+
expect(env).toEqual({ ...original, ...sessionEnv });
159+
expect(collection.vars).toEqual(sessionEnv);
154160
});
155161

156162
it("does not overwrite existing lowercase variables", () => {
@@ -166,7 +172,7 @@ describe("applySshEnvironment", () => {
166172
env,
167173
);
168174

169-
expect(env).toEqual({ ...original, ...proxyEnv });
175+
expect(env).toEqual({ ...original, ...proxyEnv, ...sessionEnv });
170176

171177
applied.dispose();
172178
expect(env).toEqual(original);

0 commit comments

Comments
 (0)