Skip to content

Commit fcf29b1

Browse files
Emit PostHog AI observability events in privacy mode (#417)
* Emit PostHog AI observability spans and generations per turn PostHog's LLM analytics views query the $ai_-prefixed properties and nothing else, so the documented names are not ours to choose: an unprefixed property still arrives on the event but is invisible to every trace, cost, and latency view. $ai_latency is a duration in seconds, while the runtime measures milliseconds throughout. Only ids, enums, and counts leave the process. A tool name and a provider error message are both free text that routinely embed a local path or a prompt excerpt, so each is classified into a fixed enum at the boundary and the original discarded. The trace is deliberately flat: PostHog accepts a trace id as a span's parent, and top-level tool calls are all the turn record exposes. * Retire the inference_turn event in favour of $ai_generation Both events fired from the same hook with the same payload, so every turn was reported twice, and only $ai_generation reaches PostHog's LLM analytics views — inference_turn's numbers were readable in the raw event stream and nowhere else. This is a deliberate removal of the duplicate, not an oversight: nothing outside this repository consumed inference_turn.
1 parent b0ef2bd commit fcf29b1

8 files changed

Lines changed: 839 additions & 48 deletions

File tree

docs/TELEMETRY.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Each event carries a small set of properties:
1212
|---|---|---|
1313
| `cli_start` | Once per used session (see First-run disclosure) | (none beyond common properties) |
1414
| `session_end` | When a TUI session finishes | `status`, `turn_count`, `duration_ms`, `session_mode`, `exit_reason` |
15-
| `inference_turn` | Once per completed turn | `provider_id`, `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `thinking_tokens`, `duration_ms` |
15+
| `$ai_generation` | Once per turn — on completion, and once for a turn that ends in an error instead | `$ai_trace_id`, `$ai_provider`, `$ai_model`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_is_error`, `$ai_error`, `cache_read_tokens`, `cache_write_tokens`, `thinking_tokens` |
16+
| `$ai_span` | Once per top-level tool call in a completed turn | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_is_error` |
1617
| `slash_command` | A slash command is dispatched in the TUI | `command_name` |
1718
| `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) |
1819
| `plugin_loaded` | A plugin is discovered and loaded at startup | `origin` |
@@ -37,9 +38,9 @@ request IP; no location data is collected by the client.
3738
Every event is capped to an explicit property allowlist before it leaves the
3839
process — no other field can ever be attached, even by accident.
3940

40-
`provider_id` is the canonical provider kind resolved by the runtime (e.g.
41+
`$ai_provider` is the canonical provider kind resolved by the runtime (e.g.
4142
`openai-compatible`), never the free-text name you gave the provider in
42-
onboarding or settings. `model_id` is the model identifier exactly as
43+
onboarding or settings. `$ai_model` is the model identifier exactly as
4344
configured — it is the one user-entered string that is sent, so do not put
4445
anything identifying in a model name.
4546

@@ -75,6 +76,53 @@ The mapping is `src/telemetry/classify.ts`, and the tests that feed each
7576
emission site a deliberately identifying name and assert it reaches no part of
7677
the payload are in `tests/unit/telemetry-product-events.test.ts`.
7778

79+
## AI observability events
80+
81+
`$ai_generation` and `$ai_span` are the two PostHog AI observability events,
82+
emitted from `src/telemetry/ai-observability.ts`. PostHog's LLM analytics
83+
views query the `$ai_`-prefixed properties and nothing else, which is why
84+
these names are not ours to choose. `$ai_latency` is a duration in **seconds**
85+
as a float, per PostHog's schema — the runtime measures milliseconds and
86+
converts.
87+
88+
The trace is **flat**. Every turn gets one `$ai_trace_id` derived from the
89+
runtime's session id and the turn index; the turn's `$ai_generation` and each
90+
of its `$ai_span`s carry it, and every span's `$ai_parent_id` is that same
91+
trace id rather than another span. PostHog documents `$ai_parent_id` as
92+
accepting either a trace id or a span id, so this is a legal trace, and it is
93+
all the runtime can honestly describe: the turn record only exposes top-level
94+
tool calls. No `$ai_trace` event is emitted — PostHog synthesises the trace
95+
from its children.
96+
97+
`$ai_span_id` is the provider-generated opaque tool call id. It identifies
98+
the call within the trace and carries nothing else.
99+
100+
`$ai_span_name` is one of a fixed enum (`tool_call`, `subagent_call`). The raw
101+
tool name is never sent: an MCP tool name embeds the server identifier it was
102+
configured under, which can be a local path.
103+
104+
`$ai_error` is likewise one of a fixed enum (`rate_limit`, `auth`, `timeout`,
105+
`cancelled`, `inference_failed`). The provider's error message is classified
106+
into one of these and then discarded — a raw message routinely embeds the
107+
request URL, a prompt excerpt, or a file path.
108+
109+
The cache and thinking token counts keep unprefixed names because PostHog does
110+
not publish property names for them in its manual-capture schema; a guessed
111+
`$ai_` name would land as an unread custom property either way.
112+
113+
Stopping a turn mid-inference is reported, not silent: the runtime aborts the
114+
in-flight call and classifies the resulting error as `cancelled`, so a stopped
115+
turn produces the same errored `$ai_generation` as a failed one and is told
116+
apart by `$ai_error`. A turn that never reaches inference at all — suspended
117+
at an approval prompt and never resumed — emits nothing, because the runtime
118+
raises no event for it.
119+
120+
Exactly one `$ai_generation` is ever emitted per turn. A single give-up
121+
usually surfaces twice at the event stream (the failed inference, then the
122+
reactor terminating), and a turn that already reported completion is finished;
123+
`src/session/run-sink.ts` latches on both so neither can double-count a turn
124+
or append a phantom failure to a successful one.
125+
78126
## What's never collected
79127

80128
- Prompts, model output, or any conversation content

src/session/run-sink.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,98 @@ describe("createRunSink", () => {
7777
expect(runSink.getTokenUsage()).toEqual({ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 });
7878
});
7979

80+
test("reports the in-flight turn to onTurnFailed when a turn errors instead of completing", () => {
81+
const failures: { turnIndex: number; error: string }[] = [];
82+
const runSink = createRunSink({
83+
emitter: new EventEmitter(),
84+
hookManager: stubHookManager([]),
85+
onTurnFailed: (info) => failures.push(info),
86+
});
87+
88+
runSink.sink(event("inference.start", {}));
89+
runSink.sink(event("inference.error", { error: { message: "429 rate limit" } }));
90+
91+
expect(failures).toEqual([{ turnIndex: 0, error: "429 rate limit" }]);
92+
});
93+
94+
// Regression: one give-up reaches the sink twice — the director surfaces
95+
// the failed inference, then the reactor terminates the run — and reporting
96+
// both files two failed turns under a single turn's identity.
97+
test("reports one failure per turn across both error paths, not one per error event", () => {
98+
const failures: { turnIndex: number; error: string }[] = [];
99+
const runSink = createRunSink({
100+
emitter: new EventEmitter(),
101+
hookManager: stubHookManager([]),
102+
onTurnFailed: (info) => failures.push(info),
103+
});
104+
105+
runSink.sink(event("inference.start", {}));
106+
runSink.sink(event("inference.error", { error: { message: "429 rate limit" } }));
107+
runSink.sink(event("reactor.error", { error: "reactor gave up" }));
108+
109+
expect(failures).toEqual([{ turnIndex: 0, error: "429 rate limit" }]);
110+
});
111+
112+
test("reports no failure for a turn that already completed", () => {
113+
const failures: { turnIndex: number; error: string }[] = [];
114+
const completions: number[] = [];
115+
const runSink = createRunSink({
116+
emitter: new EventEmitter(),
117+
hookManager: stubHookManager([]),
118+
onTurnComplete: (ctx) => completions.push(ctx.turnIndex),
119+
onTurnFailed: (info) => failures.push(info),
120+
});
121+
122+
runSink.sink(event("inference.start", {}));
123+
runSink.sink(event("inference.done", {
124+
turn: { role: "assistant", content: [], model: "test", timestamp: 0 },
125+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
126+
source: { provider: "test", model: "test" },
127+
}));
128+
runSink.sink(event("reactor.error", { error: "reactor gave up at shutdown" }));
129+
130+
expect(completions).toEqual([0]);
131+
expect(failures).toEqual([]);
132+
});
133+
134+
// A retry re-enters inference.start under the same turn index, so a second
135+
// report would land on the trace id the first one already claimed.
136+
test("reports one failure for a turn that fails, retries, and fails again", () => {
137+
const failures: { turnIndex: number; error: string }[] = [];
138+
const runSink = createRunSink({
139+
emitter: new EventEmitter(),
140+
hookManager: stubHookManager([]),
141+
onTurnFailed: (info) => failures.push(info),
142+
});
143+
144+
runSink.sink(event("inference.start", {}));
145+
runSink.sink(event("inference.error", { error: { message: "500 upstream" } }));
146+
runSink.sink(event("inference.start", {}));
147+
runSink.sink(event("inference.error", { error: { message: "500 upstream again" } }));
148+
149+
expect(failures).toEqual([{ turnIndex: 0, error: "500 upstream" }]);
150+
});
151+
152+
test("still reports a failure after reset clears the latch", () => {
153+
const failures: { turnIndex: number; error: string }[] = [];
154+
const runSink = createRunSink({
155+
emitter: new EventEmitter(),
156+
hookManager: stubHookManager([]),
157+
onTurnFailed: (info) => failures.push(info),
158+
});
159+
160+
runSink.sink(event("inference.start", {}));
161+
runSink.sink(event("inference.error", { error: { message: "first session" } }));
162+
runSink.reset();
163+
runSink.sink(event("inference.start", {}));
164+
runSink.sink(event("inference.error", { error: { message: "second session" } }));
165+
166+
expect(failures).toEqual([
167+
{ turnIndex: 0, error: "first session" },
168+
{ turnIndex: 0, error: "second session" },
169+
]);
170+
});
171+
80172
test("seeds the turn count from a resumed session's prior turnsUsed", () => {
81173
const runSink = createRunSink({
82174
emitter: new EventEmitter(),

src/session/run-sink.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export type RunSinkArgs = {
2020
// turn actually ran against, so consumers report per-turn provider/model
2121
// even if the live selection changed mid-run.
2222
onTurnComplete?: (ctx: import("./hooks.js").TurnContext) => void;
23+
// Fired at most once per turn, when that turn ends in an error instead of
24+
// completing. onTurnComplete only ever sees turns that produced a full
25+
// TurnContext, so a consumer relying on it alone goes silent exactly when a
26+
// run goes wrong. The turn index is the collector's current count: the
27+
// in-flight turn is the one that would have been recorded next.
28+
onTurnFailed?: (info: { turnIndex: number; error: string }) => void;
2329
// Continues a resumed session's persisted run.json turn count instead of
2430
// restarting the collector at zero.
2531
initialTurnCount?: number;
@@ -81,7 +87,8 @@ export function resolveExecRunStatus(args: {
8187
}
8288

8389
export function createRunSink(args: RunSinkArgs): RunSink {
84-
const { emitter, hookManager, onTurnComplete, initialTurnCount, onTurnBoundarySnapshot } = args;
90+
const { emitter, hookManager, onTurnComplete, onTurnFailed, initialTurnCount, onTurnBoundarySnapshot } =
91+
args;
8592

8693
function hasConfiguredHooks(): boolean {
8794
return hookManager.getStatuses().length > 0;
@@ -110,12 +117,36 @@ export function createRunSink(args: RunSinkArgs): RunSink {
110117
let runCompleted = false;
111118
let runError: string | undefined;
112119
let turnCollector = createCollector(initialTurnCount);
120+
// True between `inference.start` and whichever event settles that turn.
121+
// One give-up reaches this sink twice — the director surfaces the failed
122+
// inference, then the reactor terminates the run — and a turn that already
123+
// completed is finished, so a later shutdown error belongs to no turn at
124+
// all. Both cases resolve to the same question: is there a turn in flight
125+
// for this error to be about?
126+
let turnInFlight = false;
127+
// Retries re-enter `inference.start` without advancing the turn count, so
128+
// a second failure on a retried turn would report the index a consumer
129+
// already recorded a failure for. Consumers key per-turn identity off that
130+
// index, which makes a repeat indistinguishable from a duplicate.
131+
let failedTurnIndex: number | null = null;
113132
// Always-on local PerfTrace: not gated by lifecycle hooks.
114133
let perfObserver = createPerfReactorObserver();
115134

135+
function reportTurnFailure(error: string): void {
136+
if (!turnInFlight) return;
137+
turnInFlight = false;
138+
const turnIndex = turnCollector.getTurnCount();
139+
if (turnIndex === failedTurnIndex) return;
140+
failedTurnIndex = turnIndex;
141+
onTurnFailed?.({ turnIndex, error });
142+
}
143+
116144
const sink = (event: ReactorEmittedEvent): void => {
117145
turnCollector.observe(event);
118146
perfObserver.observe(event);
147+
if (event.type === "inference.start") {
148+
turnInFlight = true;
149+
}
119150
if (event.type === "reactor.done") {
120151
runCompleted = true;
121152
// Terminal success clears any earlier transient inference error.
@@ -125,16 +156,19 @@ export function createRunSink(args: RunSinkArgs): RunSink {
125156
// (ChatDirector retries timeout/retryable/aborted). Leaving the sticky error
126157
// would mark a recovered successful send as failed.
127158
if (onTurnBoundary(event)) {
159+
turnInFlight = false;
128160
runError = undefined;
129161
onTurnBoundarySnapshot?.();
130162
}
131163
if (event.type === "reactor.error") {
132164
const data = event.data as { error: string };
133165
runError = data.error;
166+
reportTurnFailure(data.error);
134167
}
135168
if (event.type === "inference.error") {
136169
const data = event.data as { error: { message: string } };
137170
runError = data.error.message;
171+
reportTurnFailure(data.error.message);
138172
}
139173
emitter.emit("event", event);
140174
};
@@ -151,6 +185,8 @@ export function createRunSink(args: RunSinkArgs): RunSink {
151185
reset: () => {
152186
runCompleted = false;
153187
runError = undefined;
188+
turnInFlight = false;
189+
failedTurnIndex = null;
154190
turnCollector = createCollector();
155191
perfObserver.reset();
156192
},

0 commit comments

Comments
 (0)