Skip to content

Commit f759c0e

Browse files
authored
feat(observability): name AI traces in PostHog with a $ai_trace envelope (#10223)
PostHog's Traces view takes a trace's NAME from a trace-level $ai_trace event, not from any property on the generations underneath it -- confirmed upstream in PostHog/posthog#33179, where an $ai_span_name set on a generation does not populate that column. This project emitted only generations, so every trace read as an anonymous id with no way to tell a gate review from an embedding batch. Emit the envelope from withReviewPipelineSpan, which already wraps a whole review and carries the repo, PR and the ambient OTel trace id the generations group by. It runs INSIDE the OTel span, because that id only exists once the span is open. Two guards, both load-bearing: - Outermost only. withReviewPipelineSpan is called from several sites that can nest, and PostHog expects one $ai_trace per trace. A depth counter means inner calls contribute nothing and the surviving name is the whole operation rather than whichever leaf finished last. - Only when generations exist. Not every pipeline span wraps an AI call -- the gate does not. Emitting unconditionally would manufacture trace rows with nothing under them, which reads worse than an unnamed trace. A pass-through when PostHog is off or no ambient trace exists, so it never changes what the wrapped work does or what it throws. Closes #10221
1 parent e664c35 commit f759c0e

5 files changed

Lines changed: 238 additions & 2 deletions

File tree

apps/loopover-ui/content/docs/self-hosting-operations.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,11 @@ three standalone bindings (`AI_EMBED`, `AI_VISION`, `AI_ADVISORY`).
683683
description:
684684
"$ai_model is always the model the provider actually resolved — on the failure path too. The core passes a Workers-AI model id that every self-host provider discards, so it is resolved to the real one (or <provider>-default) before capture and never reported verbatim.",
685685
},
686+
{
687+
title: "Trace naming",
688+
description:
689+
"$ai_trace names the whole review in PostHog's Traces view. Emitted once per trace, by the outermost pipeline span, and only when at least one AI call actually ran under it — a pipeline span with no generations is never given a trace row.",
690+
},
686691
{
687692
title: "Degraded requests",
688693
description:

src/selfhost/posthog.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,9 +507,108 @@ function repoGroup(operational: Record<string, unknown>): { groups?: { repo: str
507507
return typeof operational.repo === "string" ? { groups: { repo: operational.repo } } : {};
508508
}
509509

510+
/** #10221: PostHog's Traces view takes a trace's NAME from a trace-level `$ai_trace` event, not from any
511+
* property on the generations underneath it (confirmed upstream in PostHog/posthog#33179 -- an
512+
* `$ai_span_name` on a generation does not populate that column). This project emitted only generations, so
513+
* every trace read as an anonymous id with no way to tell a gate review from an embedding batch.
514+
*
515+
* Per in-flight trace: how deeply {@link withPostHogAiTrace} is nested, and whether any generation actually
516+
* landed underneath. Both matter -- see that function for why. */
517+
const aiTraceState = new Map<string, { depth: number; generations: number }>();
518+
519+
/** Note that a generation landed under `traceId`, so its enclosing pipeline span knows the trace is worth
520+
* naming. A generation captured outside any pipeline span has no entry and is simply ignored. */
521+
function markAiTraceGeneration(traceId: unknown): void {
522+
if (typeof traceId !== "string") return;
523+
const state = aiTraceState.get(traceId);
524+
if (state) state.generations += 1;
525+
}
526+
527+
export const POSTHOG_AI_TRACE_EVENT = "$ai_trace";
528+
529+
/**
530+
* Name the AI trace a pipeline span represents (#10221), emitting one `$ai_trace` as the OUTERMOST span for
531+
* that trace completes.
532+
*
533+
* Two conditions guard the emit, and both are load-bearing:
534+
*
535+
* - **Outermost only.** `withReviewPipelineSpan` is called from several sites that can nest, and PostHog
536+
* expects one `$ai_trace` per trace id. A depth counter means the inner calls contribute nothing and the
537+
* name that survives is the outermost one -- the whole operation, not whichever leaf happened to finish.
538+
* - **Only when generations exist.** Not every pipeline span wraps an AI call (the gate, for one). Emitting
539+
* unconditionally would manufacture trace rows with zero generations under them, which is a worse reading
540+
* than an unnamed trace.
541+
*
542+
* A no-op when PostHog is off or there is no ambient OTel trace to name -- in both cases the callback runs
543+
* untouched, so this never changes what the wrapped work does or what it throws.
544+
*/
545+
export async function withPostHogAiTrace<T>(
546+
name: string,
547+
context: Record<string, unknown> | undefined,
548+
fn: () => T | Promise<T>,
549+
): Promise<T> {
550+
const traceId = currentOtelTraceIds()?.trace_id;
551+
// Pin the client that was active when the span opened, rather than re-reading the module binding in the
552+
// `finally` below -- it also lets the emit helper stay free of a second, unreachable off-switch check.
553+
const target = client;
554+
if (!active || !target || !traceId) return await fn();
555+
const state = aiTraceState.get(traceId) ?? { depth: 0, generations: 0 };
556+
if (state.depth === 0) aiTraceState.set(traceId, state);
557+
state.depth += 1;
558+
const startedAtMs = Date.now();
559+
let failure: unknown;
560+
try {
561+
return await fn();
562+
} catch (error) {
563+
failure = error;
564+
throw error;
565+
} finally {
566+
state.depth -= 1;
567+
if (state.depth === 0) {
568+
aiTraceState.delete(traceId);
569+
if (state.generations > 0) captureAiTraceEnvelope(target, name, context, traceId, Date.now() - startedAtMs, failure);
570+
}
571+
}
572+
}
573+
574+
/** Emit the trace-level envelope. Separate from {@link withPostHogAiTrace} only so the bookkeeping above
575+
* reads as bookkeeping. */
576+
function captureAiTraceEnvelope(
577+
target: PostHogClient,
578+
name: string,
579+
context: Record<string, unknown> | undefined,
580+
traceId: string,
581+
latencyMs: number,
582+
failure: unknown,
583+
): void {
584+
const operational = operationalProperties(context);
585+
const properties: Record<string, unknown> = {
586+
...operational,
587+
// The trace id is the one the generations already carry -- taken from the SAME ambient OTel trace, so the
588+
// envelope and its children can never disagree about which trace they belong to.
589+
$ai_trace_id: traceId,
590+
$ai_span_name: name,
591+
$ai_latency: latencyMs / 1000,
592+
$ai_is_error: failure !== undefined,
593+
environment: posthogEnvironment,
594+
};
595+
if (failure !== undefined) {
596+
const error = failure instanceof Error ? failure : new Error(String(failure));
597+
properties.$ai_error = error.message.slice(0, 500);
598+
}
599+
target.capture({
600+
distinctId: POSTHOG_DISTINCT_ID,
601+
event: POSTHOG_AI_TRACE_EVENT,
602+
properties,
603+
...repoGroup(operational),
604+
});
605+
}
606+
510607
export function capturePostHogAiGeneration(event: PostHogAiGenerationEvent): void {
511608
if (!active || !client) return;
512609
const operational = operationalProperties(event.context);
610+
// #10221: tells the enclosing pipeline span this trace has real AI work under it and is worth naming.
611+
markAiTraceGeneration(operational.trace_id);
513612
const properties: Record<string, unknown> = {
514613
...operational,
515614
...aiTraceProperties(operational),
@@ -677,5 +776,7 @@ export function resetPostHogForTest(): void {
677776
centralKeyAnonSecret = undefined;
678777
aiContentCapture = false;
679778
aiContentMaxChars = DEFAULT_AI_CONTENT_MAX_CHARS;
779+
// #10221: in-flight trace bookkeeping, so one test's pipeline span cannot leak into the next.
780+
aiTraceState.clear();
680781
resetRedactionScrubForTest();
681782
}

src/selfhost/review-tracing.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sha256Hex } from "../utils/crypto";
22
import { setCurrentOtelSpanAttributes, withOtelSpan } from "./otel";
3+
import { withPostHogAiTrace } from "./posthog";
34

45
const INSTALLATION_HASH_SEED = "github-installation:";
56

@@ -54,7 +55,13 @@ export async function withReviewPipelineSpan<T>(
5455
input: ReviewTraceInput,
5556
fn: () => T | Promise<T>,
5657
): Promise<T> {
57-
return withOtelSpan(name, await reviewTraceAttributes(input), fn);
58+
// #10221: withPostHogAiTrace runs INSIDE the OTel span, because the trace id it names the trace by is the
59+
// ambient one this span establishes -- the same id capturePostHogAiGeneration stamps on every generation
60+
// underneath it. Outside the span there is nothing yet to name. It is a pass-through whenever PostHog is
61+
// off or the trace turns out to hold no AI calls, so a non-AI pipeline span is unaffected.
62+
return withOtelSpan(name, await reviewTraceAttributes(input), () =>
63+
withPostHogAiTrace(name, { repo: input.repoFullName, pullNumber: input.pullNumber }, fn),
64+
);
5865
}
5966

6067
export async function setReviewPipelineSpanOutcome(

test/unit/docs-selfhost-posthog-observability.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync } from "node:fs";
22
import { describe, expect, it } from "vitest";
33

4-
import { POSTHOG_AI_DEGRADED_EVENT, POSTHOG_MONITOR_HEARTBEAT_EVENT } from "../../src/selfhost/posthog";
4+
import { POSTHOG_AI_DEGRADED_EVENT, POSTHOG_AI_TRACE_EVENT, POSTHOG_MONITOR_HEARTBEAT_EVENT } from "../../src/selfhost/posthog";
55

66
// Drift guard (#8287, #1468 -- 2026-07-25 Sentry removal): self-host PostHog docs must stay aligned with the
77
// exported monitor-heartbeat event name. Sentry's own docs test (docs-selfhost-sentry-observability.test.ts)
@@ -49,6 +49,11 @@ describe("self-host PostHog observability docs (#8287)", () => {
4949
expect(operations).toContain("private source code leaving your infrastructure");
5050
});
5151

52+
it("documents the trace envelope with its real exported event name (#10221)", () => {
53+
expect(operations).toContain(POSTHOG_AI_TRACE_EVENT);
54+
expect(operations).toContain("Trace naming");
55+
});
56+
5257
it("documents the cron-monitor heartbeat replacement with the real exported event name", () => {
5358
expect(operations).toContain("Cron Monitors");
5459
expect(operations).toContain(POSTHOG_MONITOR_HEARTBEAT_EVENT);

test/unit/selfhost-posthog.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ import {
3535
capturePostHogAiMetric,
3636
POSTHOG_AI_DEGRADED_EVENT,
3737
POSTHOG_AI_METRIC_EVENT,
38+
POSTHOG_AI_TRACE_EVENT,
3839
POSTHOG_MONITOR_HEARTBEAT_EVENT,
40+
withPostHogAiTrace,
3941
resetPostHogForTest,
4042
resolvePostHogRelease,
4143
scrubPostHogEvent,
@@ -1019,6 +1021,122 @@ describe("capturePostHogAiMetric (#10226 — review quality, joined to the AI tr
10191021
});
10201022
});
10211023

1024+
describe("withPostHogAiTrace (#10221 — naming the trace PostHog's Traces view shows)", () => {
1025+
const GENERATION = { provider: "claude-code", model: "claude-sonnet-5", requestKind: "review" as const, latencyMs: 1500, isError: false };
1026+
const traceEvents = (): Array<{ properties: Record<string, unknown>; groups?: unknown }> =>
1027+
mocks.capture.mock.calls.map((call) => call[0]).filter((call) => call.event === POSTHOG_AI_TRACE_EVENT);
1028+
1029+
it("is a transparent pass-through when PostHog is unconfigured", async () => {
1030+
await expect(withPostHogAiTrace("review.gate", undefined, async () => "result")).resolves.toBe("result");
1031+
expect(mocks.capture).not.toHaveBeenCalled();
1032+
});
1033+
1034+
it("is a pass-through when there is no ambient OTel trace to name", async () => {
1035+
otelMocks.currentOtelTraceIds.mockReturnValue(undefined);
1036+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1037+
await expect(withPostHogAiTrace("review.gate", undefined, async () => "result")).resolves.toBe("result");
1038+
expect(traceEvents()).toHaveLength(0);
1039+
});
1040+
1041+
it("names the trace once a generation has landed under it", async () => {
1042+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "review-trace-1", span_id: "span-1" });
1043+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1044+
await withPostHogAiTrace("review.pipeline", { repo: "owner/repo", pullNumber: 7 }, async () => {
1045+
capturePostHogAiGeneration({ ...GENERATION, context: { repo: "owner/repo", pullNumber: 7 } });
1046+
});
1047+
const [envelope] = traceEvents();
1048+
// The envelope must claim the SAME trace id the generation carries, or the two never join up.
1049+
expect(envelope?.properties.$ai_trace_id).toBe("review-trace-1");
1050+
expect(envelope?.properties.$ai_span_name).toBe("review.pipeline");
1051+
expect(envelope?.properties.$ai_is_error).toBe(false);
1052+
expect(envelope?.properties.repo).toBe("owner/repo");
1053+
expect(envelope?.groups).toEqual({ repo: "owner/repo" });
1054+
});
1055+
1056+
it("does NOT name a pipeline span that ran no AI calls at all", async () => {
1057+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "gate-trace", span_id: "span-1" });
1058+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1059+
// The gate is a real pipeline span with no generation under it. Naming it would manufacture a trace row
1060+
// with nothing in it, which reads worse than an unnamed trace.
1061+
await withPostHogAiTrace("review.gate", { repo: "owner/repo" }, async () => "no ai here");
1062+
expect(traceEvents()).toHaveLength(0);
1063+
});
1064+
1065+
it("emits exactly ONE envelope for nested spans, named by the OUTERMOST one", async () => {
1066+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "nested-trace", span_id: "span-1" });
1067+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1068+
await withPostHogAiTrace("review.outer", { repo: "owner/repo" }, async () => {
1069+
await withPostHogAiTrace("review.inner", { repo: "owner/repo" }, async () => {
1070+
capturePostHogAiGeneration({ ...GENERATION, context: { repo: "owner/repo" } });
1071+
});
1072+
});
1073+
const envelopes = traceEvents();
1074+
expect(envelopes).toHaveLength(1);
1075+
// The whole operation, not whichever leaf happened to finish.
1076+
expect(envelopes[0]?.properties.$ai_span_name).toBe("review.outer");
1077+
});
1078+
1079+
it("marks the trace errored and rethrows when the wrapped work throws", async () => {
1080+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "failing-trace", span_id: "span-1" });
1081+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1082+
await expect(
1083+
withPostHogAiTrace("review.pipeline", { repo: "owner/repo" }, async () => {
1084+
capturePostHogAiGeneration({ ...GENERATION, context: { repo: "owner/repo" } });
1085+
throw new Error("reviewer exploded");
1086+
}),
1087+
).rejects.toThrow("reviewer exploded");
1088+
const [envelope] = traceEvents();
1089+
expect(envelope?.properties.$ai_is_error).toBe(true);
1090+
expect(envelope?.properties.$ai_error).toBe("reviewer exploded");
1091+
});
1092+
1093+
it("handles a non-Error thrown value, and bounds the recorded message", async () => {
1094+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "string-throw-trace", span_id: "span-1" });
1095+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1096+
await expect(
1097+
withPostHogAiTrace("review.pipeline", undefined, async () => {
1098+
capturePostHogAiGeneration(GENERATION);
1099+
throw "a string failure";
1100+
}),
1101+
).rejects.toBe("a string failure");
1102+
expect(traceEvents()[0]?.properties.$ai_error).toBe("a string failure");
1103+
1104+
mocks.capture.mockClear();
1105+
await expect(
1106+
withPostHogAiTrace("review.pipeline", undefined, async () => {
1107+
capturePostHogAiGeneration(GENERATION);
1108+
throw new Error("y".repeat(600));
1109+
}),
1110+
).rejects.toThrow();
1111+
expect(traceEvents()[0]?.properties.$ai_error).toHaveLength(500);
1112+
});
1113+
1114+
it("omits the repo group when the span has no repo, and still names the trace", async () => {
1115+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "no-repo-trace", span_id: "span-1" });
1116+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1117+
await withPostHogAiTrace("ai.advisory", undefined, async () => {
1118+
capturePostHogAiGeneration(GENERATION);
1119+
});
1120+
const [envelope] = traceEvents();
1121+
expect(envelope?.properties.$ai_span_name).toBe("ai.advisory");
1122+
expect(envelope && "groups" in envelope).toBe(false);
1123+
});
1124+
1125+
it("a second, later span over the SAME trace id starts from a clean slate", async () => {
1126+
otelMocks.currentOtelTraceIds.mockReturnValue({ trace_id: "reused-trace", span_id: "span-1" });
1127+
await initPostHog({ POSTHOG_API_KEY: "phc_test_key" } as unknown as NodeJS.ProcessEnv);
1128+
await withPostHogAiTrace("first", undefined, async () => {
1129+
capturePostHogAiGeneration(GENERATION);
1130+
});
1131+
// The bookkeeping entry is deleted on completion, so the second span must not inherit the first's
1132+
// generation count and name an empty trace.
1133+
await withPostHogAiTrace("second", undefined, async () => "no ai here");
1134+
const envelopes = traceEvents();
1135+
expect(envelopes).toHaveLength(1);
1136+
expect(envelopes[0]?.properties.$ai_span_name).toBe("first");
1137+
});
1138+
});
1139+
10221140
describe("flushPostHog / shutdownPostHog", () => {
10231141
it("flushPostHog is a no-op when unconfigured", async () => {
10241142
await flushPostHog();

0 commit comments

Comments
 (0)