Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 4cf4612

Browse files
feat(aio): declare session id and per-turn traceparent to the llm gateway (#3927)
1 parent 6b65b0f commit 4cf4612

6 files changed

Lines changed: 181 additions & 4 deletions

File tree

packages/agent/src/adapters/claude/session/options.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,18 @@ describe("buildSessionOptions", () => {
376376
name: "omits the team_id header when POSTHOG_PROJECT_ID is unset",
377377
projectId: undefined,
378378
existingHeaders: undefined,
379-
expected: "x-posthog-use-bedrock-fallback: true",
379+
expected: [
380+
"x-posthog-property-$ai_session_id: test-session",
381+
"x-posthog-use-bedrock-fallback: true",
382+
].join("\n"),
380383
},
381384
{
382385
name: "forwards POSTHOG_PROJECT_ID as the team_id attribution header",
383386
projectId: "42",
384387
existingHeaders: undefined,
385388
expected: [
386389
"x-posthog-property-team_id: 42",
390+
"x-posthog-property-$ai_session_id: test-session",
387391
"x-posthog-use-bedrock-fallback: true",
388392
].join("\n"),
389393
},
@@ -394,6 +398,7 @@ describe("buildSessionOptions", () => {
394398
expected: [
395399
"x-posthog-property-task_id: task-abc",
396400
"x-posthog-property-team_id: 42",
401+
"x-posthog-property-$ai_session_id: test-session",
397402
"x-posthog-use-bedrock-fallback: true",
398403
].join("\n"),
399404
},
@@ -411,6 +416,101 @@ describe("buildSessionOptions", () => {
411416
expect(headers).toBe(expected);
412417
});
413418
});
419+
420+
describe("gateway turn tracing env", () => {
421+
const KEYS = [
422+
"CLAUDE_CODE_ENABLE_TELEMETRY",
423+
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA",
424+
"CLAUDE_CODE_PROPAGATE_TRACEPARENT",
425+
"OTEL_TRACES_EXPORTER",
426+
"OTEL_EXPORTER_OTLP_PROTOCOL",
427+
"OTEL_EXPORTER_OTLP_ENDPOINT",
428+
"TRACEPARENT",
429+
"TRACESTATE",
430+
] as const;
431+
const original: Partial<Record<string, string | undefined>> = {};
432+
433+
beforeEach(() => {
434+
for (const key of KEYS) {
435+
original[key] = process.env[key];
436+
delete process.env[key];
437+
}
438+
});
439+
440+
afterEach(() => {
441+
for (const key of KEYS) {
442+
const value = original[key];
443+
if (value === undefined) {
444+
delete process.env[key];
445+
} else {
446+
process.env[key] = value;
447+
}
448+
}
449+
});
450+
451+
const gatewayEnv = {
452+
anthropicBaseUrl: "https://gateway.example",
453+
anthropicAuthToken: "tok",
454+
openaiBaseUrl: "https://gateway.example/v1",
455+
openaiApiKey: "tok",
456+
};
457+
458+
it("enables per-turn traceparent when routed through the gateway", () => {
459+
const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env;
460+
461+
expect(env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1");
462+
expect(env?.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA).toBe("1");
463+
expect(env?.CLAUDE_CODE_PROPAGATE_TRACEPARENT).toBe("1");
464+
expect(env?.OTEL_TRACES_EXPORTER).toBe("otlp");
465+
expect(env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json");
466+
expect(env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("http://127.0.0.1:9");
467+
});
468+
469+
it("honors a caller-supplied OTLP endpoint", () => {
470+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
471+
"http://collector.internal:4318";
472+
473+
const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env;
474+
475+
expect(env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
476+
"http://collector.internal:4318",
477+
);
478+
});
479+
480+
it("pins exporter and protocol so an inherited none can't disable tracing", () => {
481+
process.env.OTEL_TRACES_EXPORTER = "none";
482+
process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
483+
484+
const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env;
485+
486+
expect(env?.OTEL_TRACES_EXPORTER).toBe("otlp");
487+
expect(env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json");
488+
});
489+
490+
it("strips inherited TRACEPARENT so turns keep distinct trace ids", () => {
491+
process.env.TRACEPARENT =
492+
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
493+
process.env.TRACESTATE = "vendor=x";
494+
495+
const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env;
496+
497+
expect(env?.TRACEPARENT).toBeUndefined();
498+
expect(env?.TRACESTATE).toBeUndefined();
499+
});
500+
501+
it("leaves BYOK sessions untouched", () => {
502+
process.env.TRACEPARENT =
503+
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
504+
505+
const env = buildSessionOptions(makeParams()).env;
506+
507+
expect(env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBeUndefined();
508+
expect(env?.CLAUDE_CODE_PROPAGATE_TRACEPARENT).toBeUndefined();
509+
expect(env?.TRACEPARENT).toBe(
510+
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
511+
);
512+
});
513+
});
414514
});
415515

416516
describe("buildSystemPrompt", () => {

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ function buildMcpServers(
151151
};
152152
}
153153

154-
function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
154+
function buildEnvironment(
155+
gateway?: GatewayEnv,
156+
sessionId?: string,
157+
): Record<string, string> {
155158
// Custom HTTP headers reach the model only through the Claude CLI subprocess,
156159
// which reads them from this env var (newline-delimited `name: value` lines)
157160
// — the SDK has no direct header option. We finalize them here, the single
@@ -174,6 +177,11 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
174177
if (projectId) {
175178
headerLines.push(buildGatewayPropertyHeaders({ team_id: projectId }));
176179
}
180+
if (sessionId) {
181+
headerLines.push(
182+
buildGatewayPropertyHeaders({ $ai_session_id: sessionId }),
183+
);
184+
}
177185
// Route to AWS Bedrock as a fallback when Anthropic returns 5xx
178186
headerLines.push("x-posthog-use-bedrock-fallback: true");
179187
const customHeaders = headerLines.join("\n");
@@ -185,8 +193,31 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
185193
// sessions that genuinely need MCP tools available on turn 1.
186194
const mcpNonblocking = process.env.MCP_CONNECTION_NONBLOCKING;
187195

188-
return {
196+
// Every var is load-bearing (ablation-tested): the CLI stamps the per-turn
197+
// traceparent only once its OTel tracer initializes, and the dead endpoint
198+
// keeps the throwaway spans off any local collector. Exporter and protocol
199+
// are pinned rather than inherited — an ambient OTEL_TRACES_EXPORTER=none or
200+
// unknown protocol registers no tracer and silently drops the traceparent;
201+
// the endpoint stays overridable for a real collector.
202+
// Residual risk: a repo's .claude/settings.json `env` is applied over these
203+
// inside the CLI and can redirect the endpoint or turn on content capture
204+
// (OTEL_LOG_TOOL_CONTENT, …) — pre-existing settingSources exposure, not
205+
// closable from here; hardening tracked separately.
206+
const gatewayTracing: Record<string, string> = gateway?.anthropicBaseUrl
207+
? {
208+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
209+
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1",
210+
CLAUDE_CODE_PROPAGATE_TRACEPARENT: "1",
211+
OTEL_TRACES_EXPORTER: "otlp",
212+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
213+
OTEL_EXPORTER_OTLP_ENDPOINT:
214+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://127.0.0.1:9",
215+
}
216+
: {};
217+
218+
const env: Record<string, string> = {
189219
...process.env,
220+
...gatewayTracing,
190221
// Explicit gateway values win over whatever happens to be in process.env.
191222
// This prevents concurrent Agent instances from clobbering each other's
192223
// gateway config when process.env was mutated globally.
@@ -212,6 +243,13 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
212243
}),
213244
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
214245
};
246+
if (gateway?.anthropicBaseUrl) {
247+
// The CLI parents every turn under an inherited ambient TRACEPARENT,
248+
// collapsing the per-turn trace ids this block exists to produce.
249+
delete env.TRACEPARENT;
250+
delete env.TRACESTATE;
251+
}
252+
return env;
215253
}
216254

217255
function buildHooks(
@@ -473,7 +511,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
473511
params.mcpServers,
474512
loadUserClaudeJsonMcpServers(params.cwd, params.logger),
475513
),
476-
env: buildEnvironment(params.gatewayEnv),
514+
env: buildEnvironment(params.gatewayEnv, params.sessionId),
477515
hooks: buildHooks(
478516
params.userProvidedOptions?.hooks,
479517
params.onModeChange,

packages/agent/src/adapters/codex-app-server/spawn.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ describe("buildAppServerArgs", () => {
5757
);
5858
});
5959

60+
it("quotes $-prefixed posthog property header keys in the TOML table", () => {
61+
const args = buildAppServerArgs({
62+
binaryPath: "/bundle/codex",
63+
apiBaseUrl: "https://gateway.example/v1",
64+
httpHeaders: { "x-posthog-property-$ai_session_id": "task-123" },
65+
});
66+
67+
expect(args).toContain(
68+
'model_providers.posthog.http_headers={ "x-posthog-property-$ai_session_id" = "task-123" }',
69+
);
70+
});
71+
6072
it("omits http_headers when none are provided or the provider is unset", () => {
6173
const withoutHeaders = buildAppServerArgs({
6274
binaryPath: "/bundle/codex",

packages/agent/src/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api";
1515
import { SessionLogWriter } from "./session-log-writer";
1616
import type { AgentConfig, TaskExecutionOptions } from "./types";
17+
import { buildGatewayPropertyHeaderRecord } from "./utils/gateway";
1718
import { Logger } from "./utils/logger";
1819

1920
export class Agent {
@@ -148,6 +149,9 @@ export class Agent {
148149
model: sanitizedModel,
149150
reasoningEffort: options.reasoningEffort,
150151
developerInstructions: options.developerInstructions,
152+
httpHeaders: taskId
153+
? buildGatewayPropertyHeaderRecord({ $ai_session_id: taskId })
154+
: undefined,
151155
additionalDirectories: options.additionalDirectories,
152156
}
153157
: undefined,

packages/agent/src/server/agent-server.configure-environment.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ describe("AgentServer.configureEnvironment", () => {
223223
"x-posthog-property-task_user_id": "42",
224224
"x-posthog-property-task_title": "Fix the bug",
225225
"x-posthog-property-team_id": "1",
226+
"x-posthog-property-$ai_session_id": "task-abc",
226227
});
227228
});
228229

@@ -340,6 +341,25 @@ describe("AgentServer.configureEnvironment", () => {
340341
);
341342
});
342343

344+
it("folds the task id into the codex session header only", () => {
345+
const env = buildServer("interactive").configureEnvironment({
346+
taskId: "task-123",
347+
});
348+
349+
expect(env.openaiCustomHeaders?.["x-posthog-property-$ai_session_id"]).toBe(
350+
"task-123",
351+
);
352+
expect(env.anthropicCustomHeaders ?? "").not.toContain("$ai_session_id");
353+
});
354+
355+
it("omits the codex session header without a task id", () => {
356+
const env = buildServer("interactive").configureEnvironment({});
357+
358+
expect(
359+
env.openaiCustomHeaders?.["x-posthog-property-$ai_session_id"],
360+
).toBeUndefined();
361+
});
362+
343363
it("appends the resolved product to a LLM_GATEWAY_URL override base", () => {
344364
// The override is treated as a base URL. The product slug is always
345365
// appended so the gateway routes to the correct product config — a bare

packages/agent/src/server/agent-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3975,9 +3975,12 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}${
39753975
openaiCustomHeaders = buildGatewayPropertiesHeaderRecord(properties);
39763976
} else {
39773977
customHeaders = buildGatewayPropertyHeaders(gatewayProperties);
3978+
// No $ai_session_id on the Go-gateway path above: it strips $-prefixed
3979+
// blob keys, so the session id would be silently dropped there.
39783980
openaiCustomHeaders = buildGatewayPropertyHeaderRecord({
39793981
...gatewayProperties,
39803982
team_id: projectId,
3983+
$ai_session_id: taskId,
39813984
});
39823985
}
39833986

0 commit comments

Comments
 (0)