From 4d12a6732c475b8c2955d7e77a4e858545f85c28 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 1 Jun 2026 22:25:16 +0100 Subject: [PATCH 1/4] feat(agent): attribute LLM gateway events to the customer team Every LLM call from the agent package flows through the PostHog LLM gateway, which authenticates with a shared key. Without a per-request team override, every captured `$ai_generation` event is attributed to the key owner's team, so per-customer spend can't be rolled up. Mirror django's `get_llm_client(team_id=...)` (PostHog/posthog#60745): forward the team as an `x-posthog-property-team_id` header, which the gateway lifts onto the captured event as a `team_id` property. - Cloud path (`agent-server.ts`): add `team_id` to the task-metadata headers already built in `configureEnvironment`. - Desktop path (`agent.ts`): set the `team_id` property header in `_configureLlmGateway`, deduping by header name so re-configuring across sessions doesn't append it twice and existing headers (e.g. the Bedrock fallback) are preserved. - Expose `PostHogAPIClient.getProjectId()` for the desktop path. Generated-By: PostHog Code Task-Id: a2593f98-9dc7-4fa7-a532-5257b2d5c6b9 --- .../agent/src/agent.configure-gateway.test.ts | 72 +++++++++++++++++++ packages/agent/src/agent.ts | 44 ++++++++++++ packages/agent/src/posthog-api.test.ts | 10 +++ packages/agent/src/posthog-api.ts | 4 ++ ...agent-server.configure-environment.test.ts | 14 +++- packages/agent/src/server/agent-server.ts | 6 +- 6 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/agent/src/agent.configure-gateway.test.ts diff --git a/packages/agent/src/agent.configure-gateway.test.ts b/packages/agent/src/agent.configure-gateway.test.ts new file mode 100644 index 0000000000..74118f3752 --- /dev/null +++ b/packages/agent/src/agent.configure-gateway.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Agent } from "./agent"; + +interface TestableAgent { + _configureLlmGateway( + overrideUrl?: string, + ): Promise<{ gatewayUrl: string; apiKey: string } | null>; +} + +const ENV_KEYS_UNDER_TEST = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_CUSTOM_HEADERS", + "OPENAI_BASE_URL", + "OPENAI_API_KEY", +] as const; + +describe("Agent._configureLlmGateway", () => { + const originalEnv: Partial> = {}; + + beforeEach(() => { + for (const key of ENV_KEYS_UNDER_TEST) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS_UNDER_TEST) { + const value = originalEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + const buildAgent = (): TestableAgent => + new Agent({ + skipLogPersistence: true, + posthog: { + apiUrl: "https://us.posthog.com", + getApiKey: vi.fn().mockResolvedValue("test-token"), + projectId: 99, + }, + }) as unknown as TestableAgent; + + it("forwards the team_id as an x-posthog-property header", async () => { + await buildAgent()._configureLlmGateway(); + + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( + "x-posthog-property-team_id: 99", + ); + }); + + it("preserves pre-existing custom headers and dedupes the team_id line", async () => { + process.env.ANTHROPIC_CUSTOM_HEADERS = [ + "x-posthog-property-team_id: 1", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"); + + await buildAgent()._configureLlmGateway(); + + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( + [ + "x-posthog-property-team_id: 99", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + ); + }); +}); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 4456e63be3..192ddbb725 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -11,6 +11,7 @@ import { import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; import type { AgentConfig, TaskExecutionOptions } from "./types"; +import { buildGatewayPropertyHeaders } from "./utils/gateway"; import { Logger } from "./utils/logger"; export class Agent { @@ -67,6 +68,17 @@ export class Agent { process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = apiKey; + // Attribute every captured $ai_generation event to this team. The gateway + // authenticates with a shared key, so without the `team_id` property the + // spend lands on the key owner's team. Forwarded as an + // `x-posthog-property-team_id` header that the gateway lifts onto the + // event (the Claude session builder appends its own headers to this in + // adapters/claude/session/options.ts). Mirrors the cloud path in + // server/agent-server.ts and django's get_llm_client(team_id=...). + this._applyGatewayPropertyHeaders({ + team_id: this.posthogAPI.getProjectId(), + }); + return { gatewayUrl, apiKey }; } catch (error) { this.logger.error("Failed to configure LLM gateway", error); @@ -74,6 +86,38 @@ export class Agent { } } + /** + * Merge `x-posthog-property-*` header lines into `ANTHROPIC_CUSTOM_HEADERS`, + * deduping by header name so re-configuring across sessions doesn't append + * the same property twice. Existing non-property lines are preserved. + */ + private _applyGatewayPropertyHeaders( + properties: Record, + ): void { + const lines = new Map(); + const existing = process.env.ANTHROPIC_CUSTOM_HEADERS; + if (existing) { + for (const line of existing.split("\n")) { + const name = line.slice(0, line.indexOf(":")).trim(); + if (name) { + lines.set(name, line); + } + } + } + + const additions = buildGatewayPropertyHeaders(properties); + if (additions) { + for (const line of additions.split("\n")) { + const name = line.slice(0, line.indexOf(":")).trim(); + lines.set(name, line); + } + } + + process.env.ANTHROPIC_CUSTOM_HEADERS = Array.from(lines.values()).join( + "\n", + ); + } + async run( taskId: string, taskRunId: string, diff --git a/packages/agent/src/posthog-api.test.ts b/packages/agent/src/posthog-api.test.ts index 16f2b37061..a2afae2dad 100644 --- a/packages/agent/src/posthog-api.test.ts +++ b/packages/agent/src/posthog-api.test.ts @@ -10,6 +10,16 @@ describe("PostHogAPIClient", () => { vi.clearAllMocks(); }); + it("exposes the configured project id", () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 42, + }); + + expect(client.getProjectId()).toBe(42); + }); + it("refreshes once when fetching task run logs gets an auth failure", async () => { const getApiKey = vi.fn().mockResolvedValue("stale-token"); const refreshApiKey = vi.fn().mockResolvedValue("fresh-token"); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index fb0c161d1a..08debfa77d 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -133,6 +133,10 @@ export class PostHogAPIClient { return this.config.projectId; } + getProjectId(): number { + return this.config.projectId; + } + async getApiKey(forceRefresh = false): Promise { return this.resolveApiKey(forceRefresh); } 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 fd81726ad8..7790a11b59 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -134,6 +134,7 @@ describe("AgentServer.configureEnvironment", () => { expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( [ + "x-posthog-property-team_id: 1", "x-posthog-property-task_origin_product: signal_report", "x-posthog-property-task_internal: true", "x-posthog-property-signal_report_id: report-123", @@ -144,6 +145,14 @@ describe("AgentServer.configureEnvironment", () => { ); }); + it("always forwards the team_id as an x-posthog-property header", () => { + buildServer("background").configureEnvironment({ isInternal: false }); + + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toContain( + "x-posthog-property-team_id: 1", + ); + }); + it("omits signal_report_id from ANTHROPIC_CUSTOM_HEADERS for non-report tasks", () => { buildServer("background").configureEnvironment({ isInternal: false, @@ -159,7 +168,10 @@ describe("AgentServer.configureEnvironment", () => { buildServer("background").configureEnvironment({ isInternal: false }); expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( - "x-posthog-property-task_internal: false", + [ + "x-posthog-property-team_id: 1", + "x-posthog-property-task_internal: false", + ].join("\n"), ); }); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index c5307e44ff..70eabd477d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1893,8 +1893,12 @@ ${signedCommitInstructions} // Forward task metadata as `x-posthog-property-*` headers so the gateway // lifts them onto the $ai_generation event. Routes through the Anthropic // SDK's ANTHROPIC_CUSTOM_HEADERS env var; the OpenAI/codex path has no - // equivalent today. + // equivalent today. `team_id` attributes every captured generation to the + // customer's PostHog team (the gateway authenticates with a shared key, so + // without this the spend lands on the key owner's team — see the django + // `get_llm_client(team_id=...)` equivalent in posthog/llm/gateway_client.py). const customHeaders = buildGatewayPropertyHeaders({ + team_id: projectId, task_origin_product: originProduct, task_internal: isInternal, signal_report_id: signalReportId, From f50f5e350b43a74d40aec585e4d8b331735cfa9f Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 1 Jun 2026 22:52:24 +0100 Subject: [PATCH 2/4] refactor(agent): consolidate team_id header into the single session chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-site implementation (desktop `agent.ts` + cloud `agent-server.ts`) with a single definition in the Claude session builder's `buildEnvironment`, where `ANTHROPIC_CUSTOM_HEADERS` is already finalized for every session. Both entrypoints already export `POSTHOG_PROJECT_ID` (apps/code auth-adapter.ts, server/agent-server.ts), so reading it there covers desktop and cloud uniformly. `buildEnvironment` returns a fresh env object rather than mutating `process.env`, so the cross-session dedup helper is no longer needed — and the `PostHogAPIClient.getProjectId()` accessor and the `agent.ts` changes are dropped entirely. Net: `team_id` attribution is defined once, the diff is smaller, and the cloud header block keeps a comment pointing to where it now lives. Generated-By: PostHog Code Task-Id: a2593f98-9dc7-4fa7-a532-5257b2d5c6b9 --- .../adapters/claude/session/options.test.ts | 64 ++++++++++++++++- .../src/adapters/claude/session/options.ts | 26 +++++-- .../agent/src/agent.configure-gateway.test.ts | 72 ------------------- packages/agent/src/agent.ts | 44 ------------ packages/agent/src/posthog-api.test.ts | 10 --- packages/agent/src/posthog-api.ts | 4 -- ...agent-server.configure-environment.test.ts | 14 +--- packages/agent/src/server/agent-server.ts | 8 +-- 8 files changed, 88 insertions(+), 154 deletions(-) delete mode 100644 packages/agent/src/agent.configure-gateway.test.ts diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 7c843dc593..4fa580a510 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -1,6 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Logger } from "../../../utils/logger"; import { SUBAGENT_REWRITES } from "../hooks"; import { buildSessionOptions } from "./options"; @@ -70,4 +70,66 @@ describe("buildSessionOptions", () => { expect(options.agents?.["ph-explore"]).toEqual(override); }); + + describe("ANTHROPIC_CUSTOM_HEADERS", () => { + const originalProjectId = process.env.POSTHOG_PROJECT_ID; + const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; + + beforeEach(() => { + delete process.env.POSTHOG_PROJECT_ID; + delete process.env.ANTHROPIC_CUSTOM_HEADERS; + }); + + afterEach(() => { + for (const [key, value] of [ + ["POSTHOG_PROJECT_ID", originalProjectId], + ["ANTHROPIC_CUSTOM_HEADERS", originalCustomHeaders], + ] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("forwards POSTHOG_PROJECT_ID as the team_id attribution header", () => { + process.env.POSTHOG_PROJECT_ID = "42"; + + const headers = buildSessionOptions(makeParams()).env + ?.ANTHROPIC_CUSTOM_HEADERS; + + expect(headers).toBe( + [ + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + ); + }); + + it("preserves pre-existing custom headers ahead of the team_id header", () => { + process.env.POSTHOG_PROJECT_ID = "42"; + process.env.ANTHROPIC_CUSTOM_HEADERS = + "x-posthog-property-task_id: task-abc"; + + const headers = buildSessionOptions(makeParams()).env + ?.ANTHROPIC_CUSTOM_HEADERS; + + expect(headers).toBe( + [ + "x-posthog-property-task_id: task-abc", + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + ); + }); + + it("omits the team_id header when POSTHOG_PROJECT_ID is unset", () => { + const headers = buildSessionOptions(makeParams()).env + ?.ANTHROPIC_CUSTOM_HEADERS; + + expect(headers).toBe("x-posthog-use-bedrock-fallback: true"); + expect(headers).not.toContain("team_id"); + }); + }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index b3c2683a7a..c87fb4f096 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -112,11 +112,28 @@ function buildMcpServers( } function buildEnvironment(): Record { - const bedrockFallbackHeader = "x-posthog-use-bedrock-fallback: true"; + // 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 + // chokepoint every session (desktop and cloud) funnels through. + const headerLines: string[] = []; const existingCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; - const customHeaders = existingCustomHeaders - ? `${existingCustomHeaders}\n${bedrockFallbackHeader}` - : bedrockFallbackHeader; + if (existingCustomHeaders) { + headerLines.push(existingCustomHeaders); + } + // Attribute every captured $ai_generation event to the customer's team. The + // gateway authenticates with a shared key, so without this the spend lands on + // the key owner's team. The gateway lifts `x-posthog-property-*` headers onto + // the event; both entrypoints export POSTHOG_PROJECT_ID before this runs + // (apps/code auth-adapter.ts, server/agent-server.ts). Mirrors django's + // get_llm_client(team_id=...). + const projectId = process.env.POSTHOG_PROJECT_ID; + if (projectId) { + headerLines.push(`x-posthog-property-team_id: ${projectId}`); + } + // Route to AWS Bedrock as a fallback when Anthropic returns 5xx + headerLines.push("x-posthog-use-bedrock-fallback: true"); + const customHeaders = headerLines.join("\n"); // SDK 0.3.142 made MCP servers connect in the background by default. That // default is what we want: a slow or unreachable user MCP server (PostHog @@ -136,7 +153,6 @@ function buildEnvironment(): Record { ...(mcpNonblocking !== undefined && { MCP_CONNECTION_NONBLOCKING: mcpNonblocking, }), - // Route to AWS Bedrock as a fallback when Anthropic returns 5xx ANTHROPIC_CUSTOM_HEADERS: customHeaders, }; } diff --git a/packages/agent/src/agent.configure-gateway.test.ts b/packages/agent/src/agent.configure-gateway.test.ts deleted file mode 100644 index 74118f3752..0000000000 --- a/packages/agent/src/agent.configure-gateway.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Agent } from "./agent"; - -interface TestableAgent { - _configureLlmGateway( - overrideUrl?: string, - ): Promise<{ gatewayUrl: string; apiKey: string } | null>; -} - -const ENV_KEYS_UNDER_TEST = [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_CUSTOM_HEADERS", - "OPENAI_BASE_URL", - "OPENAI_API_KEY", -] as const; - -describe("Agent._configureLlmGateway", () => { - const originalEnv: Partial> = {}; - - beforeEach(() => { - for (const key of ENV_KEYS_UNDER_TEST) { - originalEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of ENV_KEYS_UNDER_TEST) { - const value = originalEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }); - - const buildAgent = (): TestableAgent => - new Agent({ - skipLogPersistence: true, - posthog: { - apiUrl: "https://us.posthog.com", - getApiKey: vi.fn().mockResolvedValue("test-token"), - projectId: 99, - }, - }) as unknown as TestableAgent; - - it("forwards the team_id as an x-posthog-property header", async () => { - await buildAgent()._configureLlmGateway(); - - expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( - "x-posthog-property-team_id: 99", - ); - }); - - it("preserves pre-existing custom headers and dedupes the team_id line", async () => { - process.env.ANTHROPIC_CUSTOM_HEADERS = [ - "x-posthog-property-team_id: 1", - "x-posthog-use-bedrock-fallback: true", - ].join("\n"); - - await buildAgent()._configureLlmGateway(); - - expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( - [ - "x-posthog-property-team_id: 99", - "x-posthog-use-bedrock-fallback: true", - ].join("\n"), - ); - }); -}); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 192ddbb725..4456e63be3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -11,7 +11,6 @@ import { import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; import type { AgentConfig, TaskExecutionOptions } from "./types"; -import { buildGatewayPropertyHeaders } from "./utils/gateway"; import { Logger } from "./utils/logger"; export class Agent { @@ -68,17 +67,6 @@ export class Agent { process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = apiKey; - // Attribute every captured $ai_generation event to this team. The gateway - // authenticates with a shared key, so without the `team_id` property the - // spend lands on the key owner's team. Forwarded as an - // `x-posthog-property-team_id` header that the gateway lifts onto the - // event (the Claude session builder appends its own headers to this in - // adapters/claude/session/options.ts). Mirrors the cloud path in - // server/agent-server.ts and django's get_llm_client(team_id=...). - this._applyGatewayPropertyHeaders({ - team_id: this.posthogAPI.getProjectId(), - }); - return { gatewayUrl, apiKey }; } catch (error) { this.logger.error("Failed to configure LLM gateway", error); @@ -86,38 +74,6 @@ export class Agent { } } - /** - * Merge `x-posthog-property-*` header lines into `ANTHROPIC_CUSTOM_HEADERS`, - * deduping by header name so re-configuring across sessions doesn't append - * the same property twice. Existing non-property lines are preserved. - */ - private _applyGatewayPropertyHeaders( - properties: Record, - ): void { - const lines = new Map(); - const existing = process.env.ANTHROPIC_CUSTOM_HEADERS; - if (existing) { - for (const line of existing.split("\n")) { - const name = line.slice(0, line.indexOf(":")).trim(); - if (name) { - lines.set(name, line); - } - } - } - - const additions = buildGatewayPropertyHeaders(properties); - if (additions) { - for (const line of additions.split("\n")) { - const name = line.slice(0, line.indexOf(":")).trim(); - lines.set(name, line); - } - } - - process.env.ANTHROPIC_CUSTOM_HEADERS = Array.from(lines.values()).join( - "\n", - ); - } - async run( taskId: string, taskRunId: string, diff --git a/packages/agent/src/posthog-api.test.ts b/packages/agent/src/posthog-api.test.ts index a2afae2dad..16f2b37061 100644 --- a/packages/agent/src/posthog-api.test.ts +++ b/packages/agent/src/posthog-api.test.ts @@ -10,16 +10,6 @@ describe("PostHogAPIClient", () => { vi.clearAllMocks(); }); - it("exposes the configured project id", () => { - const client = new PostHogAPIClient({ - apiUrl: "https://app.posthog.com", - getApiKey: vi.fn().mockResolvedValue("token"), - projectId: 42, - }); - - expect(client.getProjectId()).toBe(42); - }); - it("refreshes once when fetching task run logs gets an auth failure", async () => { const getApiKey = vi.fn().mockResolvedValue("stale-token"); const refreshApiKey = vi.fn().mockResolvedValue("fresh-token"); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 08debfa77d..fb0c161d1a 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -133,10 +133,6 @@ export class PostHogAPIClient { return this.config.projectId; } - getProjectId(): number { - return this.config.projectId; - } - async getApiKey(forceRefresh = false): Promise { return this.resolveApiKey(forceRefresh); } 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 7790a11b59..fd81726ad8 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -134,7 +134,6 @@ describe("AgentServer.configureEnvironment", () => { expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( [ - "x-posthog-property-team_id: 1", "x-posthog-property-task_origin_product: signal_report", "x-posthog-property-task_internal: true", "x-posthog-property-signal_report_id: report-123", @@ -145,14 +144,6 @@ describe("AgentServer.configureEnvironment", () => { ); }); - it("always forwards the team_id as an x-posthog-property header", () => { - buildServer("background").configureEnvironment({ isInternal: false }); - - expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toContain( - "x-posthog-property-team_id: 1", - ); - }); - it("omits signal_report_id from ANTHROPIC_CUSTOM_HEADERS for non-report tasks", () => { buildServer("background").configureEnvironment({ isInternal: false, @@ -168,10 +159,7 @@ describe("AgentServer.configureEnvironment", () => { buildServer("background").configureEnvironment({ isInternal: false }); expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( - [ - "x-posthog-property-team_id: 1", - "x-posthog-property-task_internal: false", - ].join("\n"), + "x-posthog-property-task_internal: false", ); }); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 70eabd477d..8c0f4aa72b 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1893,12 +1893,10 @@ ${signedCommitInstructions} // Forward task metadata as `x-posthog-property-*` headers so the gateway // lifts them onto the $ai_generation event. Routes through the Anthropic // SDK's ANTHROPIC_CUSTOM_HEADERS env var; the OpenAI/codex path has no - // equivalent today. `team_id` attributes every captured generation to the - // customer's PostHog team (the gateway authenticates with a shared key, so - // without this the spend lands on the key owner's team — see the django - // `get_llm_client(team_id=...)` equivalent in posthog/llm/gateway_client.py). + // equivalent today. (The `team_id` attribution header is added downstream + // in the Claude session builder from POSTHOG_PROJECT_ID — see + // adapters/claude/session/options.ts.) const customHeaders = buildGatewayPropertyHeaders({ - team_id: projectId, task_origin_product: originProduct, task_internal: isInternal, signal_report_id: signalReportId, From a1d6a169ce47a2f1bf978980b612834c9c52f65b Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 1 Jun 2026 23:02:55 +0100 Subject: [PATCH 3/4] test(agent): guard cloud POSTHOG_PROJECT_ID export for team_id header The team_id attribution header is emitted from POSTHOG_PROJECT_ID in the Claude session builder, so the cloud configureEnvironment must export it. The desktop path is already guarded (auth-adapter.test.ts); this closes the matching gap for the cloud path. Generated-By: PostHog Code Task-Id: a2593f98-9dc7-4fa7-a532-5257b2d5c6b9 --- .../server/agent-server.configure-environment.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 fd81726ad8..69871e5cb9 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -18,6 +18,7 @@ const ENV_KEYS_UNDER_TEST = [ "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", + "POSTHOG_PROJECT_ID", ] as const; describe("AgentServer.configureEnvironment", () => { @@ -75,6 +76,15 @@ describe("AgentServer.configureEnvironment", () => { ); }); + // The Claude session builder reads POSTHOG_PROJECT_ID to emit the + // `x-posthog-property-team_id` attribution header (see + // adapters/claude/session/options.ts), so the cloud path must export it. + it("exports POSTHOG_PROJECT_ID for the team_id attribution header", () => { + buildServer("background").configureEnvironment({ isInternal: false }); + + expect(process.env.POSTHOG_PROJECT_ID).toBe("1"); + }); + it("tags as posthog_code when isInternal is omitted (getTask failure fallback)", () => { buildServer("background").configureEnvironment(); From 9b8a2bc64a9d327fe7d24c286e132512b1cc1ad8 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Mon, 1 Jun 2026 23:19:18 +0100 Subject: [PATCH 4/4] test(agent): parameterize team_id header tests with it.each Flatten the three ANTHROPIC_CUSTOM_HEADERS cases into a single it.each table, per Greptile review feedback and the team's preference for parameterized tests. Keeps the beforeEach/afterEach env isolation so the cases stay order-independent and process.env is restored afterward. Generated-By: PostHog Code Task-Id: a2593f98-9dc7-4fa7-a532-5257b2d5c6b9 --- .../adapters/claude/session/options.test.ts | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 4fa580a510..e412e7e124 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -93,43 +93,44 @@ describe("buildSessionOptions", () => { } }); - it("forwards POSTHOG_PROJECT_ID as the team_id attribution header", () => { - process.env.POSTHOG_PROJECT_ID = "42"; - - const headers = buildSessionOptions(makeParams()).env - ?.ANTHROPIC_CUSTOM_HEADERS; - - expect(headers).toBe( - [ + it.each([ + { + name: "omits the team_id header when POSTHOG_PROJECT_ID is unset", + projectId: undefined, + existingHeaders: undefined, + expected: "x-posthog-use-bedrock-fallback: true", + }, + { + name: "forwards POSTHOG_PROJECT_ID as the team_id attribution header", + projectId: "42", + existingHeaders: undefined, + expected: [ "x-posthog-property-team_id: 42", "x-posthog-use-bedrock-fallback: true", ].join("\n"), - ); - }); - - it("preserves pre-existing custom headers ahead of the team_id header", () => { - process.env.POSTHOG_PROJECT_ID = "42"; - process.env.ANTHROPIC_CUSTOM_HEADERS = - "x-posthog-property-task_id: task-abc"; - - const headers = buildSessionOptions(makeParams()).env - ?.ANTHROPIC_CUSTOM_HEADERS; - - expect(headers).toBe( - [ + }, + { + name: "preserves pre-existing custom headers ahead of the team_id header", + projectId: "42", + existingHeaders: "x-posthog-property-task_id: task-abc", + expected: [ "x-posthog-property-task_id: task-abc", "x-posthog-property-team_id: 42", "x-posthog-use-bedrock-fallback: true", ].join("\n"), - ); - }); + }, + ])("$name", ({ projectId, existingHeaders, expected }) => { + if (projectId !== undefined) { + process.env.POSTHOG_PROJECT_ID = projectId; + } + if (existingHeaders !== undefined) { + process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders; + } - it("omits the team_id header when POSTHOG_PROJECT_ID is unset", () => { const headers = buildSessionOptions(makeParams()).env ?.ANTHROPIC_CUSTOM_HEADERS; - expect(headers).toBe("x-posthog-use-bedrock-fallback: true"); - expect(headers).not.toContain("team_id"); + expect(headers).toBe(expected); }); }); });