From 85675d0ad10bff8508eb8b00ac96064ff8fd3d65 Mon Sep 17 00:00:00 2001 From: Carlos Marchal Date: Wed, 29 Jul 2026 11:57:53 +0200 Subject: [PATCH 1/4] feat: forward the claude session id as the ai session header Generated-By: PostHog Code Task-Id: 1119b267-6fcd-4dba-bc80-f15665f7a9b1 --- .../src/adapters/claude/session/options.test.ts | 7 ++++++- .../agent/src/adapters/claude/session/options.ts | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 9698920c27..cf856fee90 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -371,7 +371,10 @@ describe("buildSessionOptions", () => { name: "omits the team_id header when POSTHOG_PROJECT_ID is unset", projectId: undefined, existingHeaders: undefined, - expected: "x-posthog-use-bedrock-fallback: true", + expected: [ + "x-posthog-property-$ai_session_id: test-session", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), }, { name: "forwards POSTHOG_PROJECT_ID as the team_id attribution header", @@ -379,6 +382,7 @@ describe("buildSessionOptions", () => { existingHeaders: undefined, expected: [ "x-posthog-property-team_id: 42", + "x-posthog-property-$ai_session_id: test-session", "x-posthog-use-bedrock-fallback: true", ].join("\n"), }, @@ -389,6 +393,7 @@ describe("buildSessionOptions", () => { expected: [ "x-posthog-property-task_id: task-abc", "x-posthog-property-team_id: 42", + "x-posthog-property-$ai_session_id: test-session", "x-posthog-use-bedrock-fallback: true", ].join("\n"), }, diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 7c1def1eec..85a249cbbc 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -149,7 +149,10 @@ function buildMcpServers( }; } -function buildEnvironment(gateway?: GatewayEnv): Record { +function buildEnvironment( + gateway?: GatewayEnv, + sessionId?: string, +): Record { // Custom HTTP headers reach the model only through the Claude CLI subprocess, // which reads them from this env var (newline-delimited `name: value` lines) // — the SDK has no direct header option. We finalize them here, the single @@ -172,6 +175,11 @@ function buildEnvironment(gateway?: GatewayEnv): Record { if (projectId) { headerLines.push(buildGatewayPropertyHeaders({ team_id: projectId })); } + if (sessionId) { + headerLines.push( + buildGatewayPropertyHeaders({ $ai_session_id: sessionId }), + ); + } // Route to AWS Bedrock as a fallback when Anthropic returns 5xx headerLines.push("x-posthog-use-bedrock-fallback: true"); const customHeaders = headerLines.join("\n"); @@ -471,7 +479,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.mcpServers, loadUserClaudeJsonMcpServers(params.cwd, params.logger), ), - env: buildEnvironment(params.gatewayEnv), + env: buildEnvironment(params.gatewayEnv, params.sessionId), hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, From 08d312a4b56d47d8c9e8e3e98bec5c61968f4ad5 Mon Sep 17 00:00:00 2001 From: Carlos Marchal Date: Wed, 29 Jul 2026 12:33:29 +0200 Subject: [PATCH 2/4] feat: enable per-turn traceparent for gateway-routed claude sessions Generated-By: PostHog Code Task-Id: 1119b267-6fcd-4dba-bc80-f15665f7a9b1 --- .../adapters/claude/session/options.test.ts | 86 +++++++++++++++++++ .../src/adapters/claude/session/options.ts | 26 +++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index cf856fee90..6f86599a4a 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -411,6 +411,92 @@ describe("buildSessionOptions", () => { expect(headers).toBe(expected); }); }); + + describe("gateway turn tracing env", () => { + const KEYS = [ + "CLAUDE_CODE_ENABLE_TELEMETRY", + "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + "CLAUDE_CODE_PROPAGATE_TRACEPARENT", + "OTEL_TRACES_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "TRACEPARENT", + "TRACESTATE", + ] as const; + const original: Partial> = {}; + + beforeEach(() => { + for (const key of KEYS) { + original[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of KEYS) { + const value = original[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + const gatewayEnv = { + anthropicBaseUrl: "https://gateway.example", + anthropicAuthToken: "tok", + openaiBaseUrl: "https://gateway.example/v1", + openaiApiKey: "tok", + }; + + it("enables per-turn traceparent when routed through the gateway", () => { + const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env; + + expect(env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1"); + expect(env?.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA).toBe("1"); + expect(env?.CLAUDE_CODE_PROPAGATE_TRACEPARENT).toBe("1"); + expect(env?.OTEL_TRACES_EXPORTER).toBe("otlp"); + expect(env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json"); + expect(env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("http://127.0.0.1:9"); + }); + + it("honors caller-supplied OTEL exporter settings", () => { + process.env.OTEL_TRACES_EXPORTER = "otlp"; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = + "http://collector.internal:4318"; + + const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env; + + expect(env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( + "http://collector.internal:4318", + ); + }); + + it("strips inherited TRACEPARENT so turns keep distinct trace ids", () => { + process.env.TRACEPARENT = + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + process.env.TRACESTATE = "vendor=x"; + + const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env; + + expect(env?.TRACEPARENT).toBeUndefined(); + expect(env?.TRACESTATE).toBeUndefined(); + }); + + it("leaves BYOK sessions untouched", () => { + process.env.TRACEPARENT = + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + + const env = buildSessionOptions(makeParams()).env; + + expect(env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBeUndefined(); + expect(env?.CLAUDE_CODE_PROPAGATE_TRACEPARENT).toBeUndefined(); + expect(env?.TRACEPARENT).toBe( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ); + }); + }); }); describe("buildSystemPrompt", () => { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 85a249cbbc..c5293d2a36 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -191,8 +191,25 @@ function buildEnvironment( // sessions that genuinely need MCP tools available on turn 1. const mcpNonblocking = process.env.MCP_CONNECTION_NONBLOCKING; - return { + // Every var is load-bearing (ablation-tested): the header is only stamped + // once the OTel tracer initializes, and the dead endpoint keeps spans from + // leaking to a local collector on the default port. Export never matters. + const gatewayTracing: Record = gateway?.anthropicBaseUrl + ? { + CLAUDE_CODE_ENABLE_TELEMETRY: "1", + CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1", + CLAUDE_CODE_PROPAGATE_TRACEPARENT: "1", + OTEL_TRACES_EXPORTER: process.env.OTEL_TRACES_EXPORTER ?? "otlp", + OTEL_EXPORTER_OTLP_PROTOCOL: + process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/json", + OTEL_EXPORTER_OTLP_ENDPOINT: + process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://127.0.0.1:9", + } + : {}; + + const env: Record = { ...process.env, + ...gatewayTracing, // Explicit gateway values win over whatever happens to be in process.env. // This prevents concurrent Agent instances from clobbering each other's // gateway config when process.env was mutated globally. @@ -218,6 +235,13 @@ function buildEnvironment( }), ANTHROPIC_CUSTOM_HEADERS: customHeaders, }; + if (gateway?.anthropicBaseUrl) { + // The CLI parents every turn under an inherited ambient TRACEPARENT, + // collapsing the per-turn trace ids this block exists to produce. + delete env.TRACEPARENT; + delete env.TRACESTATE; + } + return env; } function buildHooks( From 075c83d557d720d550cfa5d59127d5f37ae20f74 Mon Sep 17 00:00:00 2001 From: Carlos Marchal Date: Wed, 29 Jul 2026 11:59:17 +0200 Subject: [PATCH 3/4] feat: forward the task id as the codex ai session header Generated-By: PostHog Code Task-Id: 1119b267-6fcd-4dba-bc80-f15665f7a9b1 --- .../adapters/codex-app-server/spawn.test.ts | 12 +++++++++++ packages/agent/src/agent.ts | 4 ++++ ...agent-server.configure-environment.test.ts | 20 +++++++++++++++++++ packages/agent/src/server/agent-server.ts | 3 +++ 4 files changed, 39 insertions(+) diff --git a/packages/agent/src/adapters/codex-app-server/spawn.test.ts b/packages/agent/src/adapters/codex-app-server/spawn.test.ts index 6794bef2dd..a5784252d4 100644 --- a/packages/agent/src/adapters/codex-app-server/spawn.test.ts +++ b/packages/agent/src/adapters/codex-app-server/spawn.test.ts @@ -57,6 +57,18 @@ describe("buildAppServerArgs", () => { ); }); + it("quotes $-prefixed posthog property header keys in the TOML table", () => { + const args = buildAppServerArgs({ + binaryPath: "/bundle/codex", + apiBaseUrl: "https://gateway.example/v1", + httpHeaders: { "x-posthog-property-$ai_session_id": "task-123" }, + }); + + expect(args).toContain( + 'model_providers.posthog.http_headers={ "x-posthog-property-$ai_session_id" = "task-123" }', + ); + }); + it("omits http_headers when none are provided or the provider is unset", () => { const withoutHeaders = buildAppServerArgs({ binaryPath: "/bundle/codex", diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 63b3830849..b7ea903d8d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -14,6 +14,7 @@ import { import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; import type { AgentConfig, TaskExecutionOptions } from "./types"; +import { buildGatewayPropertyHeaderRecord } from "./utils/gateway"; import { Logger } from "./utils/logger"; export class Agent { @@ -148,6 +149,9 @@ export class Agent { model: sanitizedModel, reasoningEffort: options.reasoningEffort, developerInstructions: options.developerInstructions, + httpHeaders: taskId + ? buildGatewayPropertyHeaderRecord({ $ai_session_id: taskId }) + : undefined, additionalDirectories: options.additionalDirectories, } : undefined, diff --git a/packages/agent/src/server/agent-server.configure-environment.test.ts b/packages/agent/src/server/agent-server.configure-environment.test.ts index 0ae96970f6..6b18e64f22 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -223,6 +223,7 @@ describe("AgentServer.configureEnvironment", () => { "x-posthog-property-task_user_id": "42", "x-posthog-property-task_title": "Fix the bug", "x-posthog-property-team_id": "1", + "x-posthog-property-$ai_session_id": "task-abc", }); }); @@ -340,6 +341,25 @@ describe("AgentServer.configureEnvironment", () => { ); }); + it("folds the task id into the codex session header only", () => { + const env = buildServer("interactive").configureEnvironment({ + taskId: "task-123", + }); + + expect(env.openaiCustomHeaders?.["x-posthog-property-$ai_session_id"]).toBe( + "task-123", + ); + expect(env.anthropicCustomHeaders ?? "").not.toContain("$ai_session_id"); + }); + + it("omits the codex session header without a task id", () => { + const env = buildServer("interactive").configureEnvironment({}); + + expect( + env.openaiCustomHeaders?.["x-posthog-property-$ai_session_id"], + ).toBeUndefined(); + }); + it("appends the resolved product to a LLM_GATEWAY_URL override base", () => { // The override is treated as a base URL. The product slug is always // appended so the gateway routes to the correct product config — a bare diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index bb08232f2a..8711644978 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -3900,9 +3900,12 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} openaiCustomHeaders = buildGatewayPropertiesHeaderRecord(properties); } else { customHeaders = buildGatewayPropertyHeaders(gatewayProperties); + // No $ai_session_id on the Go-gateway path above: it strips $-prefixed + // blob keys, so the session id would be silently dropped there. openaiCustomHeaders = buildGatewayPropertyHeaderRecord({ ...gatewayProperties, team_id: projectId, + $ai_session_id: taskId, }); } From a7d55e954a5729258c9e64b03318d225ff782939 Mon Sep 17 00:00:00 2001 From: Carlos Marchal Date: Fri, 31 Jul 2026 17:24:50 +0200 Subject: [PATCH 4/4] fix: pin gateway trace exporter and note telemetry settings exposure Generated-By: PostHog Code Task-Id: 409fb75d-ab0b-4dca-b960-df1f721982c1 --- .../adapters/claude/session/options.test.ts | 13 +++++++++++-- .../src/adapters/claude/session/options.ts | 18 ++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 6f86599a4a..9203ed0b7a 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -461,8 +461,7 @@ describe("buildSessionOptions", () => { expect(env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("http://127.0.0.1:9"); }); - it("honors caller-supplied OTEL exporter settings", () => { - process.env.OTEL_TRACES_EXPORTER = "otlp"; + it("honors a caller-supplied OTLP endpoint", () => { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://collector.internal:4318"; @@ -473,6 +472,16 @@ describe("buildSessionOptions", () => { ); }); + it("pins exporter and protocol so an inherited none can't disable tracing", () => { + process.env.OTEL_TRACES_EXPORTER = "none"; + process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc"; + + const env = buildSessionOptions({ ...makeParams(), gatewayEnv }).env; + + expect(env?.OTEL_TRACES_EXPORTER).toBe("otlp"); + expect(env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json"); + }); + it("strips inherited TRACEPARENT so turns keep distinct trace ids", () => { process.env.TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c5293d2a36..2d549e76a0 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -191,17 +191,23 @@ function buildEnvironment( // sessions that genuinely need MCP tools available on turn 1. const mcpNonblocking = process.env.MCP_CONNECTION_NONBLOCKING; - // Every var is load-bearing (ablation-tested): the header is only stamped - // once the OTel tracer initializes, and the dead endpoint keeps spans from - // leaking to a local collector on the default port. Export never matters. + // Every var is load-bearing (ablation-tested): the CLI stamps the per-turn + // traceparent only once its OTel tracer initializes, and the dead endpoint + // keeps the throwaway spans off any local collector. Exporter and protocol + // are pinned rather than inherited — an ambient OTEL_TRACES_EXPORTER=none or + // unknown protocol registers no tracer and silently drops the traceparent; + // the endpoint stays overridable for a real collector. + // Residual risk: a repo's .claude/settings.json `env` is applied over these + // inside the CLI and can redirect the endpoint or turn on content capture + // (OTEL_LOG_TOOL_CONTENT, …) — pre-existing settingSources exposure, not + // closable from here; hardening tracked separately. const gatewayTracing: Record = gateway?.anthropicBaseUrl ? { CLAUDE_CODE_ENABLE_TELEMETRY: "1", CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1", CLAUDE_CODE_PROPAGATE_TRACEPARENT: "1", - OTEL_TRACES_EXPORTER: process.env.OTEL_TRACES_EXPORTER ?? "otlp", - OTEL_EXPORTER_OTLP_PROTOCOL: - process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/json", + OTEL_TRACES_EXPORTER: "otlp", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://127.0.0.1:9", }