Skip to content

Commit eec92bf

Browse files
committed
fix(observability): group AI generations by their real trace, and attribute spend per repo
Two faults made PostHog's AI observability structurally unable to answer the questions that matter most for a product where AI is the product. Every generation was its own trace. capturePostHogAiGeneration set $ai_trace_id to a fresh randomUUID() per call, producing 13,428 AI events across 13,428 distinct trace ids on the live project. A review fans out across RAG embeddings, both dual-review legs, N retries per model, and any self-consistency runs — and each landed as an unrelated single-event trace, so what a review actually did, and where its time and money went, was not recoverable. The fix was already in the file. operationalProperties has called currentOtelTraceIds() since #8296 and attached the real ids as plain trace_id/span_id properties; the line below then minted a UUID and PostHog grouped by that instead. withReviewPipelineSpan already wraps a whole review, so the ambient trace id is exactly the grouping key wanted — every provider attempt inside one review now nests under one PostHog trace. $ai_span_id and a readable $ai_span_name come from the same source. randomUUID() stays as the fallback rather than being removed: AI_EMBED, AI_VISION and AI_ADVISORY run outside any review span, so an orphan trace is a legitimate outcome there — but such an event must not claim a span id it does not have, so $ai_span_id is omitted rather than faked. AI spend was unattributable. Every AI event carried distinct_id "loopover-selfhost" and no $groups at all, so cost per repository was answerable only by SQL against ai_usage_events, never in PostHog. A repo group is now stamped on every event. It deliberately reads the already-processed repo value off operationalProperties rather than the raw context: under the shared LOOPOVER_CENTRAL_POSTHOG_KEY that value is HMAC-anonymized, and is dropped entirely when the anon secret has not been injected, so the group inherits that fail-closed behavior for free and a raw private repo name cannot reach the group index by this path. Both branches of both changes are covered: ambient span vs none, and repo present vs dropped. Content capture ($ai_input/$ai_output_choices) is deliberately untouched — the metadata-only policy is documented in self-hosting-operations.mdx and changing it is a privacy decision, not a wiring one. Closes #10185
1 parent 8e4a7fb commit eec92bf

2 files changed

Lines changed: 77 additions & 2 deletions

File tree

src/selfhost/posthog.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,29 @@ export type PostHogAiGenerationEvent = {
417417
* parallel concern to error tracking, not a substitute gate for it. */
418418
export function capturePostHogAiGeneration(event: PostHogAiGenerationEvent): void {
419419
if (!active || !client) return;
420+
const operational = operationalProperties(event.context);
421+
// #10185: the OTel trace this call already runs inside IS the trace PostHog should group by.
422+
// operationalProperties has computed it (as plain `trace_id`/`span_id`) since #8296, but this
423+
// function then minted a fresh randomUUID() for $ai_trace_id and threw it away -- so every
424+
// generation became its own single-event trace and the review pipeline that produced them was
425+
// never visible as one thing (13,428 events across 13,428 traces on the live project).
426+
//
427+
// withReviewPipelineSpan (./review-tracing.ts) wraps a whole review, so every provider attempt
428+
// inside it -- both dual-review legs, every retry, every self-consistency run, the RAG embeddings
429+
// -- shares that span's trace id and now nests under one PostHog trace.
430+
//
431+
// randomUUID() stays the fallback, NOT an error: AI_EMBED/AI_VISION/AI_ADVISORY and any call made
432+
// outside a review span legitimately have no ambient trace, and an orphan trace is still a valid
433+
// (if less useful) event. `$ai_span_id` is only set when there IS a real span to name.
434+
const traceId = typeof operational.trace_id === "string" ? operational.trace_id : undefined;
435+
const spanId = typeof operational.span_id === "string" ? operational.span_id : undefined;
420436
const properties: Record<string, unknown> = {
421-
...operationalProperties(event.context),
422-
$ai_trace_id: randomUUID(),
437+
...operational,
438+
$ai_trace_id: traceId ?? randomUUID(),
439+
...(spanId === undefined ? {} : { $ai_span_id: spanId }),
440+
// Names the node in the trace tree. Provider-and-kind rather than the tool/feature name, because
441+
// that is what this function actually knows -- the feature lives in ai_usage_events, not here.
442+
$ai_span_name: `ai.${event.requestKind}/${nonBlank(event.provider) ?? "unknown"}`,
423443
$ai_model: nonBlank(event.model) ?? "unknown",
424444
$ai_provider: nonBlank(event.provider) ?? "unknown",
425445
// PostHog's own $ai_generation schema reports latency in SECONDS, not ms.
@@ -440,6 +460,13 @@ export function capturePostHogAiGeneration(event: PostHogAiGenerationEvent): voi
440460
distinctId: POSTHOG_DISTINCT_ID,
441461
event: event.requestKind === "embedding" ? "$ai_embedding" : "$ai_generation",
442462
properties,
463+
// #10185: a real group, so "which repo costs the most in AI spend" is answerable in PostHog
464+
// rather than only in the ai_usage_events SQL table. Deliberately reads the ALREADY-PROCESSED
465+
// `repo` off operationalProperties rather than event.context: under the shared central key that
466+
// value is HMAC-anonymized, and when the anon secret has not been injected the key is dropped
467+
// entirely. Reusing it means the group inherits that fail-closed behavior for free -- a raw
468+
// private repo name can never reach the group index by this path.
469+
...(typeof operational.repo === "string" ? { groups: { repo: operational.repo } } : {}),
443470
});
444471
}
445472

test/unit/selfhost-posthog.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,54 @@ describe("withPostHogMonitor", () => {
601601
describe("capturePostHogAiGeneration (#8296)", () => {
602602
const BASE = { provider: "ollama", model: "llama3.1", requestKind: "review" as const, latencyMs: 1500, isError: false };
603603

604+
// #10185: trace linking + repo grouping. These are what turn a pile of orphan generations into a
605+
// reviewable pipeline and an answerable spend question, so both branches of each are pinned.
606+
it("groups the generation under the AMBIENT OTel trace rather than minting a throwaway one", async () => {
607+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "review-trace-1", span_id: "provider-span-1" });
608+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
609+
capturePostHogAiGeneration(BASE);
610+
const call = mocks.capture.mock.calls[0]?.[0];
611+
// The whole point: every provider attempt inside one withReviewPipelineSpan shares this id, so
612+
// both dual-review legs, the retries, and the RAG embeddings nest under ONE PostHog trace.
613+
expect(call.properties.$ai_trace_id).toBe("review-trace-1");
614+
expect(call.properties.$ai_span_id).toBe("provider-span-1");
615+
expect(call.properties.$ai_span_name).toBe("ai.review/ollama");
616+
});
617+
618+
it("falls back to a minted trace id, and omits $ai_span_id, when there is no ambient span", async () => {
619+
// AI_EMBED / AI_VISION / AI_ADVISORY run outside any review span. An orphan trace is a valid
620+
// event, not an error -- but it must not claim a span id it does not have.
621+
otelMocks.currentOtelTraceIds.mockReturnValue(undefined);
622+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
623+
capturePostHogAiGeneration(BASE);
624+
const call = mocks.capture.mock.calls[0]?.[0];
625+
expect(call.properties.$ai_trace_id).toEqual(expect.any(String));
626+
expect(call.properties.$ai_trace_id).not.toBe("");
627+
expect("$ai_span_id" in call.properties).toBe(false);
628+
});
629+
630+
it("names an embedding span by its own request kind", async () => {
631+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "t", span_id: "s" });
632+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
633+
capturePostHogAiGeneration({ ...BASE, requestKind: "embedding", provider: "" });
634+
expect(mocks.capture.mock.calls[0]?.[0].properties.$ai_span_name).toBe("ai.embedding/unknown");
635+
});
636+
637+
it("stamps a repo group so AI spend is attributable per repository", async () => {
638+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
639+
capturePostHogAiGeneration({ ...BASE, context: { repo: "owner/repo", pullNumber: 7 } });
640+
const call = mocks.capture.mock.calls[0]?.[0];
641+
expect(call.groups).toEqual({ repo: "owner/repo" });
642+
});
643+
644+
it("sends no group at all when no repo survives the operational allowlist", async () => {
645+
// Notably the fail-closed central-key path: when the repo is dropped rather than anonymized,
646+
// the group must be dropped with it rather than defaulting to some placeholder bucket.
647+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
648+
capturePostHogAiGeneration(BASE);
649+
expect("groups" in mocks.capture.mock.calls[0]?.[0]).toBe(false);
650+
});
651+
604652
it("is a no-op when PostHog is unconfigured", () => {
605653
capturePostHogAiGeneration(BASE);
606654
expect(mocks.capture).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)