Skip to content

feat(agent): attribute LLM gateway events to the customer team#2454

Open
joshsny wants to merge 4 commits into
mainfrom
posthog-code/agent-gateway-team-id-attribution
Open

feat(agent): attribute LLM gateway events to the customer team#2454
joshsny wants to merge 4 commits into
mainfrom
posthog-code/agent-gateway-team-id-attribution

Conversation

@joshsny
Copy link
Copy Markdown
Contributor

@joshsny joshsny commented Jun 1, 2026

Problem

Every LLM call from the packages/agent agent flows through the PostHog LLM gateway, which authenticates with a single shared personal API key. The gateway attributes each captured $ai_generation event to the key owner's team unless the request carries a per-team override. That means agent LLM spend can't be rolled up per customer team.

This mirrors the issue solved on the django side for API-key events in PostHog/posthog#60745, which added get_llm_client(team_id=...). That PR forwards the team as an x-posthog-property-team_id header, and the gateway lifts x-posthog-property-* headers onto the captured event as properties. That gateway mechanism is already live (the agent already uses it to forward task metadata), so this change is standalone and does not depend on the django PR landing.

Changes

Forward the PostHog team as an x-posthog-property-team_id header on every agent → gateway request, so all $ai_generation events get a team_id property attributing them to the correct customer team.

The header is added in one place: buildEnvironment() in the Claude session builder (adapters/claude/session/options.ts), which is the single chokepoint every session (desktop and cloud) funnels through and where ANTHROPIC_CUSTOM_HEADERS is already finalized (alongside the Bedrock-fallback header). It reads POSTHOG_PROJECT_ID, which both entrypoints already export (apps/code auth-adapter.ts for desktop, server/agent-server.ts for cloud).

Why this lives in an env var rather than a direct option: the Claude Agent SDK spawns the Claude Code CLI as a child process and that subprocess makes the HTTP calls — the SDK's Options type has no custom-header field, so ANTHROPIC_CUSTOM_HEADERS (which the CLI reads and forwards) is the only mechanism. The OpenAI/codex path has no header equivalent today — same known limitation as the existing task-metadata headers.

How did you test this?

  • Added unit tests in adapters/claude/session/options.test.ts (run, passing):
    • forwards POSTHOG_PROJECT_ID as x-posthog-property-team_id
    • preserves pre-existing custom headers ahead of the team_id header
    • omits the team_id header when POSTHOG_PROJECT_ID is unset
  • Existing server/agent-server.configure-environment.test.ts and posthog-api.test.ts still pass.
  • pnpm --filter agent typecheck — clean.
  • biome check on the changed files — clean.

The remaining failures in the full pnpm --filter agent test run are pre-existing and unrelated: test helpers invoke git commit, which is blocked in this signed-commit environment.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

joshsny added 3 commits June 1, 2026 22:25
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
…hokepoint

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
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
@joshsny joshsny marked this pull request as ready for review June 1, 2026 22:05
@joshsny joshsny requested review from a team June 1, 2026 22:05
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 1, 2026

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/agent/src/adapters/claude/session/options.test.ts:96-133
The three new tests share the same structure (set env, call `buildSessionOptions`, assert on `ANTHROPIC_CUSTOM_HEADERS`) and differ only in their inputs and expected output, which is an ideal fit for `it.each`. Flattening them into a parameterised table removes the repeated `beforeEach`/`afterEach` scaffolding from the outer describe block and aligns with the team's stated preference for parameterised tests.

```suggestion
    it.each([
      [
        "omits team_id when POSTHOG_PROJECT_ID is unset",
        undefined,
        undefined,
        "x-posthog-use-bedrock-fallback: true",
      ],
      [
        "forwards POSTHOG_PROJECT_ID as the team_id attribution header",
        "42",
        undefined,
        ["x-posthog-property-team_id: 42", "x-posthog-use-bedrock-fallback: true"].join("\n"),
      ],
      [
        "preserves pre-existing custom headers ahead of the team_id header",
        "42",
        "x-posthog-property-task_id: task-abc",
        [
          "x-posthog-property-task_id: task-abc",
          "x-posthog-property-team_id: 42",
          "x-posthog-use-bedrock-fallback: true",
        ].join("\n"),
      ],
    ] as const)(
      "%s",
      (_, projectId, existingHeaders, expected) => {
        if (projectId !== undefined) {
          process.env.POSTHOG_PROJECT_ID = projectId;
        }
        if (existingHeaders !== undefined) {
          process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders;
        }

        const headers = buildSessionOptions(makeParams()).env
          ?.ANTHROPIC_CUSTOM_HEADERS;

        expect(headers).toBe(expected);
      },
    );
```

Reviews (1): Last reviewed commit: "test(agent): guard cloud POSTHOG_PROJECT..." | Re-trigger Greptile

Comment on lines +96 to +133
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");
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The three new tests share the same structure (set env, call buildSessionOptions, assert on ANTHROPIC_CUSTOM_HEADERS) and differ only in their inputs and expected output, which is an ideal fit for it.each. Flattening them into a parameterised table removes the repeated beforeEach/afterEach scaffolding from the outer describe block and aligns with the team's stated preference for parameterised tests.

Suggested change
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");
});
it.each([
[
"omits team_id when POSTHOG_PROJECT_ID is unset",
undefined,
undefined,
"x-posthog-use-bedrock-fallback: true",
],
[
"forwards POSTHOG_PROJECT_ID as the team_id attribution header",
"42",
undefined,
["x-posthog-property-team_id: 42", "x-posthog-use-bedrock-fallback: true"].join("\n"),
],
[
"preserves pre-existing custom headers ahead of the team_id header",
"42",
"x-posthog-property-task_id: task-abc",
[
"x-posthog-property-task_id: task-abc",
"x-posthog-property-team_id: 42",
"x-posthog-use-bedrock-fallback: true",
].join("\n"),
],
] as const)(
"%s",
(_, projectId, existingHeaders, expected) => {
if (projectId !== undefined) {
process.env.POSTHOG_PROJECT_ID = projectId;
}
if (existingHeaders !== undefined) {
process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders;
}
const headers = buildSessionOptions(makeParams()).env
?.ANTHROPIC_CUSTOM_HEADERS;
expect(headers).toBe(expected);
},
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/session/options.test.ts
Line: 96-133

Comment:
The three new tests share the same structure (set env, call `buildSessionOptions`, assert on `ANTHROPIC_CUSTOM_HEADERS`) and differ only in their inputs and expected output, which is an ideal fit for `it.each`. Flattening them into a parameterised table removes the repeated `beforeEach`/`afterEach` scaffolding from the outer describe block and aligns with the team's stated preference for parameterised tests.

```suggestion
    it.each([
      [
        "omits team_id when POSTHOG_PROJECT_ID is unset",
        undefined,
        undefined,
        "x-posthog-use-bedrock-fallback: true",
      ],
      [
        "forwards POSTHOG_PROJECT_ID as the team_id attribution header",
        "42",
        undefined,
        ["x-posthog-property-team_id: 42", "x-posthog-use-bedrock-fallback: true"].join("\n"),
      ],
      [
        "preserves pre-existing custom headers ahead of the team_id header",
        "42",
        "x-posthog-property-task_id: task-abc",
        [
          "x-posthog-property-task_id: task-abc",
          "x-posthog-property-team_id: 42",
          "x-posthog-use-bedrock-fallback: true",
        ].join("\n"),
      ],
    ] as const)(
      "%s",
      (_, projectId, existingHeaders, expected) => {
        if (projectId !== undefined) {
          process.env.POSTHOG_PROJECT_ID = projectId;
        }
        if (existingHeaders !== undefined) {
          process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders;
        }

        const headers = buildSessionOptions(makeParams()).env
          ?.ANTHROPIC_CUSTOM_HEADERS;

        expect(headers).toBe(expected);
      },
    );
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant