diff --git a/packages/agent/src/utils/gateway.test.ts b/packages/agent/src/utils/gateway.test.ts index 9a0b06e3e7..2320e7f452 100644 --- a/packages/agent/src/utils/gateway.test.ts +++ b/packages/agent/src/utils/gateway.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildGatewayPropertyHeaders, getLlmGatewayUrl, resolveGatewayProduct, resolveLlmGatewayUrl, @@ -60,83 +59,6 @@ describe("resolveGatewayProduct", () => { ); }); -describe("buildGatewayPropertyHeaders", () => { - it("renders each property as an x-posthog-property header line", () => { - expect( - buildGatewayPropertyHeaders({ - task_origin_product: "signal_report", - task_internal: true, - }), - ).toBe( - "x-posthog-property-task_origin_product: signal_report\nx-posthog-property-task_internal: true", - ); - }); - - it("drops null and undefined values but keeps falsy primitives", () => { - expect( - buildGatewayPropertyHeaders({ - task_origin_product: null, - task_internal: false, - task_count: 0, - }), - ).toBe( - "x-posthog-property-task_internal: false\nx-posthog-property-task_count: 0", - ); - }); - - it("returns an empty string when no usable properties remain", () => { - expect( - buildGatewayPropertyHeaders({ - task_origin_product: null, - task_internal: undefined, - }), - ).toBe(""); - }); - - it.each([ - { - description: "LF", - title: "Fix the bug\nx-posthog-property-task_internal: true", - }, - { - description: "CRLF", - title: "Fix the bug\r\nx-posthog-property-task_internal: true", - }, - { - description: "CR", - title: "Fix the bug\rx-posthog-property-task_internal: true", - }, - { - description: "consecutive newlines", - title: "Fix the bug\n\nx-posthog-property-task_internal: true", - }, - ])( - "collapses $description in values so they cannot inject extra headers", - ({ title }) => { - expect( - buildGatewayPropertyHeaders({ - task_title: title, - task_id: "task-abc", - }), - ).toBe( - "x-posthog-property-task_title: Fix the bug x-posthog-property-task_internal: true\nx-posthog-property-task_id: task-abc", - ); - }, - ); - - it("strips characters an HTTP header value cannot carry", () => { - expect(buildGatewayPropertyHeaders({ task_title: "don’t🚀ship" })).toBe( - "x-posthog-property-task_title: dontship", - ); - }); - - it("keeps latin1 characters such as accents", () => { - expect(buildGatewayPropertyHeaders({ task_title: "café" })).toBe( - "x-posthog-property-task_title: café", - ); - }); -}); - describe("resolveLlmGatewayUrl", () => { it("appends the product slug to an env-provided base URL", () => { expect( diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index ef39ec2bb5..7f024fbb88 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -27,37 +27,7 @@ export function resolveGatewayProduct({ return "posthog_code"; } -/** - * Make a value safe to embed in an HTTP header value. Collapses newlines to - * spaces (the header block is newline-delimited) and drops characters outside - * the valid header-byte range — control chars and code points above latin1 - * (emoji, smart quotes) — which an HTTP client (e.g. undici) would otherwise - * reject before sending. ASCII is preserved. - */ -function sanitizeHeaderValue(value: string): string { - return value.replace(/[\r\n]+/g, " ").replace(/[^\x20-\x7e\x80-\xff]/g, ""); -} - -/** - * Build `x-posthog-property-: ` header lines that the LLM - * gateway lifts onto the `$ai_generation` event it captures for each call - * (see `services/llm-gateway/src/llm_gateway/request_context.py`). - * - * Returns a newline-joined string ready for `ANTHROPIC_CUSTOM_HEADERS`. - * `null`/`undefined` values are dropped; values are sanitized to be HTTP-header - * safe (see {@link sanitizeHeaderValue}). - */ -export function buildGatewayPropertyHeaders( - properties: Record, -): string { - return Object.entries(properties) - .filter(([, value]) => value !== null && value !== undefined) - .map( - ([key, value]) => - `x-posthog-property-${key}: ${sanitizeHeaderValue(String(value))}`, - ) - .join("\n"); -} +export { buildPosthogPropertyHeaderLines as buildGatewayPropertyHeaders } from "@posthog/shared/posthog-property-headers"; function getGatewayBaseUrl(posthogHost: string): string { const url = new URL(posthogHost); diff --git a/packages/core/src/git-pr/git-pr.test.ts b/packages/core/src/git-pr/git-pr.test.ts index 29f50cc915..869fcdd732 100644 --- a/packages/core/src/git-pr/git-pr.test.ts +++ b/packages/core/src/git-pr/git-pr.test.ts @@ -67,6 +67,9 @@ describe("GitPrService.generateCommitMessage", () => { expect(messages[0].content).toContain("modified: x.ts"); expect(messages[0].content).toContain("why context"); expect(options.system).toContain("commit message generator"); + expect(options.posthogProperties).toEqual({ + $ai_span_name: "commit_message", + }); }); }); @@ -98,6 +101,10 @@ describe("GitPrService.generatePrTitleAndBody", () => { expect(result.title).toBe("feat: add widget"); expect(result.body).toBe("TL;DR: adds a widget."); expect(diffSource.fetchFromRemote).toHaveBeenCalledWith("/repo"); + const [, options] = (llm.prompt as ReturnType).mock.calls[0]; + expect(options.posthogProperties).toEqual({ + $ai_span_name: "pr_description", + }); }); }); diff --git a/packages/core/src/git-pr/git-pr.ts b/packages/core/src/git-pr/git-pr.ts index 3d67958c6f..8291590cf6 100644 --- a/packages/core/src/git-pr/git-pr.ts +++ b/packages/core/src/git-pr/git-pr.ts @@ -100,7 +100,11 @@ ${truncatedDiff}${contextSection}`; const response = await this.llm.prompt( [{ role: "user", content: userMessage }], - { system, model: HELPER_GATEWAY_MODEL }, + { + system, + model: HELPER_GATEWAY_MODEL, + posthogProperties: { $ai_span_name: "commit_message" }, + }, ); return { message: response.content.trim() }; @@ -211,7 +215,12 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; const response = await this.llm.prompt( [{ role: "user", content: userMessage }], - { system, maxTokens: 2000, model: HELPER_GATEWAY_MODEL }, + { + system, + maxTokens: 2000, + model: HELPER_GATEWAY_MODEL, + posthogProperties: { $ai_span_name: "pr_description" }, + }, ); const content = response.content.trim(); diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index 7840442f8c..b333d97697 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -99,6 +99,38 @@ describe("LlmGatewayService.prompt", () => { expect(body.stream).toBe(false); }); + it("forwards posthogProperties as x-posthog-property-* request headers and skips nulls", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + await service.prompt([{ role: "user", content: "hi" }], { + posthogProperties: { + $ai_span_name: "pr_description", + task_id: 42, + is_dry_run: false, + // Null/undefined values are dropped so the gateway doesn't see + // literal "null" strings on the captured event. + unused: null, + skipped: undefined, + // Newlines and non-latin1 bytes are sanitized so an undici-backed + // fetch doesn't reject the request before it's sent. + rich: "line one\nline two — done 🎉", + }, + }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers).toMatchObject({ + "x-posthog-property-$ai_span_name": "pr_description", + "x-posthog-property-task_id": "42", + "x-posthog-property-is_dry_run": "false", + "x-posthog-property-rich": "line one line two done ", + }); + expect(init.headers).not.toHaveProperty("x-posthog-property-unused"); + expect(init.headers).not.toHaveProperty("x-posthog-property-skipped"); + }); + it("throws a typed LlmGatewayError with parsed error fields on non-ok response", async () => { const fetchMock = vi.fn().mockResolvedValue( createJsonResponse( diff --git a/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts index 13fe496707..52b3d15121 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -1,4 +1,8 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + buildPosthogPropertyHeaderRecord, + type PosthogProperties, +} from "@posthog/shared/posthog-property-headers"; import { inject, injectable } from "inversify"; import { LLM_GATEWAY_HOST, @@ -58,6 +62,13 @@ export class LlmGatewayService { model?: string; signal?: AbortSignal; timeoutMs?: number; + /** + * Free-form metadata forwarded as `x-posthog-property-` headers. + * The gateway lifts each one onto the `$ai_generation` event it + * captures, so helper callers (commit messages, PR descriptions, etc.) + * can be told apart from the agent's main generations. + */ + posthogProperties?: PosthogProperties; } = {}, ): Promise { const { @@ -66,6 +77,7 @@ export class LlmGatewayService { model = this.endpoints.defaultModel, signal, timeoutMs = 60_000, + posthogProperties, } = options; const auth = await this.auth.getValidAccessToken(); @@ -101,13 +113,18 @@ export class LlmGatewayService { else signal.addEventListener("abort", onCallerAbort, { once: true }); } + const headers: Record = { + "Content-Type": "application/json", + ...(posthogProperties + ? buildPosthogPropertyHeaderRecord(posthogProperties) + : {}), + }; + let response: Response; try { response = await this.auth.authenticatedFetch(messagesUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify(requestBody), signal: timeoutController.signal, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 3f0a2c8d02..7a1ee01192 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "./mcp-sandbox-proxy": { "types": "./dist/mcp-sandbox-proxy.d.ts", "import": "./dist/mcp-sandbox-proxy.js" + }, + "./posthog-property-headers": { + "types": "./dist/posthog-property-headers.d.ts", + "import": "./dist/posthog-property-headers.js" } }, "scripts": { diff --git a/packages/shared/src/posthog-property-headers.test.ts b/packages/shared/src/posthog-property-headers.test.ts new file mode 100644 index 0000000000..6b93ffc50e --- /dev/null +++ b/packages/shared/src/posthog-property-headers.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + buildPosthogPropertyHeaderLines, + buildPosthogPropertyHeaderRecord, +} from "./posthog-property-headers"; + +describe("buildPosthogPropertyHeaderRecord", () => { + it("returns each property as an x-posthog-property- entry", () => { + expect( + buildPosthogPropertyHeaderRecord({ + task_origin_product: "signal_report", + task_internal: true, + }), + ).toEqual({ + "x-posthog-property-task_origin_product": "signal_report", + "x-posthog-property-task_internal": "true", + }); + }); + + it("drops null and undefined values but keeps falsy primitives", () => { + expect( + buildPosthogPropertyHeaderRecord({ + task_origin_product: null, + task_internal: false, + task_count: 0, + skipped: undefined, + }), + ).toEqual({ + "x-posthog-property-task_internal": "false", + "x-posthog-property-task_count": "0", + }); + }); + + it("returns an empty record when no usable properties remain", () => { + expect( + buildPosthogPropertyHeaderRecord({ + task_origin_product: null, + task_internal: undefined, + }), + ).toEqual({}); + }); + + it("collapses newline variants so a value cannot inject extra headers", () => { + expect( + buildPosthogPropertyHeaderRecord({ + task_title: "Fix the bug\r\nx-posthog-property-injected: true", + }), + ).toEqual({ + "x-posthog-property-task_title": + "Fix the bug x-posthog-property-injected: true", + }); + }); + + it("strips characters an HTTP header value cannot carry", () => { + expect( + buildPosthogPropertyHeaderRecord({ task_title: "don’t🚀ship" }), + ).toEqual({ "x-posthog-property-task_title": "dontship" }); + }); + + it("keeps latin1 characters such as accents", () => { + expect(buildPosthogPropertyHeaderRecord({ task_title: "café" })).toEqual({ + "x-posthog-property-task_title": "café", + }); + }); +}); + +describe("buildPosthogPropertyHeaderLines", () => { + it("renders each property as an x-posthog-property header line", () => { + expect( + buildPosthogPropertyHeaderLines({ + task_origin_product: "signal_report", + task_internal: true, + }), + ).toBe( + "x-posthog-property-task_origin_product: signal_report\nx-posthog-property-task_internal: true", + ); + }); + + it("drops null and undefined values but keeps falsy primitives", () => { + expect( + buildPosthogPropertyHeaderLines({ + task_origin_product: null, + task_internal: false, + task_count: 0, + }), + ).toBe( + "x-posthog-property-task_internal: false\nx-posthog-property-task_count: 0", + ); + }); + + it("returns an empty string when no usable properties remain", () => { + expect( + buildPosthogPropertyHeaderLines({ + task_origin_product: null, + task_internal: undefined, + }), + ).toBe(""); + }); + + it.each([ + { + description: "LF", + title: "Fix the bug\nx-posthog-property-task_internal: true", + }, + { + description: "CRLF", + title: "Fix the bug\r\nx-posthog-property-task_internal: true", + }, + { + description: "CR", + title: "Fix the bug\rx-posthog-property-task_internal: true", + }, + { + description: "consecutive newlines", + title: "Fix the bug\n\nx-posthog-property-task_internal: true", + }, + ])( + "collapses $description in values so they cannot inject extra headers", + ({ title }) => { + expect( + buildPosthogPropertyHeaderLines({ + task_title: title, + task_id: "task-abc", + }), + ).toBe( + "x-posthog-property-task_title: Fix the bug x-posthog-property-task_internal: true\nx-posthog-property-task_id: task-abc", + ); + }, + ); +}); diff --git a/packages/shared/src/posthog-property-headers.ts b/packages/shared/src/posthog-property-headers.ts new file mode 100644 index 0000000000..8430a61df5 --- /dev/null +++ b/packages/shared/src/posthog-property-headers.ts @@ -0,0 +1,54 @@ +export type PosthogPropertyValue = string | number | boolean | null | undefined; + +export type PosthogProperties = Record; + +/** + * Make a value safe to embed in an HTTP header value. Collapses newlines to + * spaces (the header block is newline-delimited) and drops characters outside + * the valid header-byte range — control chars and code points above latin1 + * (emoji, smart quotes) — which an HTTP client (e.g. undici) would otherwise + * reject before sending. ASCII is preserved. + */ +function sanitizeHeaderValue(value: string): string { + return value.replace(/[\r\n]+/g, " ").replace(/[^\x20-\x7e\x80-\xff]/g, ""); +} + +function buildEntries(properties: PosthogProperties): Array<[string, string]> { + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(properties)) { + if (value === null || value === undefined) continue; + entries.push([ + `x-posthog-property-${key}`, + sanitizeHeaderValue(String(value)), + ]); + } + return entries; +} + +/** + * Build a `Record` of `x-posthog-property-` headers + * suitable for `fetch()` init.headers. The LLM gateway lifts each header + * onto the `$ai_generation` event it captures + * (see `services/llm-gateway/src/llm_gateway/request_context.py` in + * posthog/posthog). `null`/`undefined` values are dropped; values are + * sanitized via {@link sanitizeHeaderValue}. + */ +export function buildPosthogPropertyHeaderRecord( + properties: PosthogProperties, +): Record { + return Object.fromEntries(buildEntries(properties)); +} + +/** + * Same property semantics as {@link buildPosthogPropertyHeaderRecord}, but + * returns a newline-joined string of `key: value` lines — the format + * `ANTHROPIC_CUSTOM_HEADERS` expects when wiring headers into the Claude + * Agent SDK. + */ +export function buildPosthogPropertyHeaderLines( + properties: PosthogProperties, +): string { + return buildEntries(properties) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); +} diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index 742102c8e1..4d3231a785 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/dismissalReasons.ts", "src/domain-types.ts", "src/mcp-sandbox-proxy.ts", + "src/posthog-property-headers.ts", "src/types.ts", ], format: ["esm"],