Skip to content

Commit d9524d9

Browse files
committed
Emit PostHog AI observability spans and generations per turn
Adds $ai_generation (one per model turn, under a per-turn $ai_trace_id derived from the process session id plus turn index) and $ai_span (one per tool call, linked via $ai_parent_id, classified into a fixed tool_call/subagent_call enum rather than the raw tool name). Reuses the existing capture path, distinct_id, and session_id plumbing; tool execution appears only as spans, never a separate product event.
1 parent 3f26458 commit d9524d9

5 files changed

Lines changed: 320 additions & 3 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { ToolCall, ToolResult } from "@intx/types/runtime";
3+
import type { Telemetry } from "./index.js";
4+
import type { TurnContext } from "../session/hooks.js";
5+
import { classifySpanKind, emitAiObservability, turnTraceId } from "./ai-observability.js";
6+
7+
const SUBAGENT_TOOL_NAME = "task";
8+
9+
function fakeTelemetry(): { telemetry: Telemetry; captured: { event: string; properties: Record<string, unknown> }[] } {
10+
const captured: { event: string; properties: Record<string, unknown> }[] = [];
11+
const telemetry: Telemetry = {
12+
enabled: true,
13+
capture: (event, properties = {}) => {
14+
captured.push({ event, properties });
15+
},
16+
flush: async () => {},
17+
};
18+
return { telemetry, captured };
19+
}
20+
21+
function fakeTurnContext(overrides: Partial<TurnContext> = {}): TurnContext {
22+
const toolCalls: ToolCall[] = [
23+
{
24+
id: "call-1",
25+
name: "read_file",
26+
arguments: { path: "/Users/attacker/secret-project/plan.md" },
27+
},
28+
{
29+
id: "call-2",
30+
name: SUBAGENT_TOOL_NAME,
31+
arguments: { description: "explore", prompt: "find the leaked API key XYZ-SECRET-123" },
32+
},
33+
];
34+
const toolResults: ToolResult[] = [
35+
{ callId: "call-1", content: "file contents: super secret prompt text" },
36+
{ callId: "call-2", content: "sub-agent report containing prompt XYZ-SECRET-123", isError: true },
37+
];
38+
return {
39+
turnIndex: 3,
40+
assistantTurn: {
41+
role: "assistant",
42+
content: [{ type: "text", text: "here is the plan: XYZ-SECRET-123" }],
43+
model: "claude-x",
44+
timestamp: 0,
45+
},
46+
toolCalls,
47+
toolResults,
48+
usage: { input: 10, output: 20, cacheRead: 1, cacheWrite: 2, thinking: 3 },
49+
source: { provider: "anthropic", model: "claude-x" },
50+
durationMs: 456,
51+
...overrides,
52+
} as TurnContext;
53+
}
54+
55+
describe("classifySpanKind", () => {
56+
test("classifies the subagent tool as subagent_call", () => {
57+
expect(classifySpanKind(SUBAGENT_TOOL_NAME, SUBAGENT_TOOL_NAME)).toBe("subagent_call");
58+
});
59+
60+
test("classifies every other tool as tool_call, regardless of name", () => {
61+
expect(classifySpanKind("read_file", SUBAGENT_TOOL_NAME)).toBe("tool_call");
62+
expect(classifySpanKind("mcp__acme__fetch_secret", SUBAGENT_TOOL_NAME)).toBe("tool_call");
63+
});
64+
});
65+
66+
describe("turnTraceId", () => {
67+
test("derives from an explicit session id and turn index rather than inventing a random id", () => {
68+
expect(turnTraceId(3, "session-abc")).toBe("session-abc:turn:3");
69+
expect(turnTraceId(3, "session-abc")).toBe(turnTraceId(3, "session-abc"));
70+
});
71+
});
72+
73+
describe("emitAiObservability", () => {
74+
test("emits one $ai_generation and one $ai_span per tool call", () => {
75+
const { telemetry, captured } = fakeTelemetry();
76+
const ctx = fakeTurnContext();
77+
78+
emitAiObservability(telemetry, ctx, { subagentToolName: SUBAGENT_TOOL_NAME });
79+
80+
expect(captured.length).toBe(3);
81+
expect(captured[0]?.event).toBe("$ai_generation");
82+
expect(captured[1]?.event).toBe("$ai_span");
83+
expect(captured[2]?.event).toBe("$ai_span");
84+
});
85+
86+
test("tree linkage: generation and spans share $ai_trace_id, spans carry $ai_parent_id", () => {
87+
const { telemetry, captured } = fakeTelemetry();
88+
const ctx = fakeTurnContext();
89+
90+
emitAiObservability(telemetry, ctx, { subagentToolName: SUBAGENT_TOOL_NAME });
91+
92+
const traceId = turnTraceId(ctx.turnIndex);
93+
const generation = captured.find((c) => c.event === "$ai_generation")!;
94+
const spans = captured.filter((c) => c.event === "$ai_span");
95+
96+
expect(generation.properties.$ai_trace_id).toBe(traceId);
97+
for (const span of spans) {
98+
expect(span.properties.$ai_trace_id).toBe(traceId);
99+
expect(span.properties.$ai_parent_id).toBe(traceId);
100+
}
101+
expect(spans[0]?.properties.$ai_span_id).toBe("call-1");
102+
expect(spans[1]?.properties.$ai_span_id).toBe("call-2");
103+
});
104+
105+
test("classifies the tool-call span kind by fixed enum, never the raw tool name", () => {
106+
const { telemetry, captured } = fakeTelemetry();
107+
const ctx = fakeTurnContext();
108+
109+
emitAiObservability(telemetry, ctx, { subagentToolName: SUBAGENT_TOOL_NAME });
110+
111+
const spans = captured.filter((c) => c.event === "$ai_span");
112+
expect(spans[0]?.properties.span_kind).toBe("tool_call");
113+
expect(spans[1]?.properties.span_kind).toBe("subagent_call");
114+
for (const span of spans) {
115+
expect(span.properties.span_kind).not.toBe("read_file");
116+
expect(span.properties.span_kind).not.toBe(SUBAGENT_TOOL_NAME);
117+
}
118+
});
119+
120+
test("propagates tool error status onto the span without the result content", () => {
121+
const { telemetry, captured } = fakeTelemetry();
122+
const ctx = fakeTurnContext();
123+
124+
emitAiObservability(telemetry, ctx, { subagentToolName: SUBAGENT_TOOL_NAME });
125+
126+
const spans = captured.filter((c) => c.event === "$ai_span");
127+
expect(spans[0]?.properties.status).toBe("ok");
128+
expect(spans[1]?.properties.status).toBe("error");
129+
});
130+
131+
test("never leaks prompt text, tool arguments, tool results, or file paths", () => {
132+
const { telemetry, captured } = fakeTelemetry();
133+
const ctx = fakeTurnContext();
134+
135+
emitAiObservability(telemetry, ctx, { subagentToolName: SUBAGENT_TOOL_NAME });
136+
137+
const serialized = JSON.stringify(captured);
138+
expect(serialized).not.toContain("secret-project");
139+
expect(serialized).not.toContain("plan.md");
140+
expect(serialized).not.toContain("XYZ-SECRET-123");
141+
expect(serialized).not.toContain("super secret prompt text");
142+
expect(serialized).not.toContain("find the leaked");
143+
expect(serialized).not.toContain("here is the plan");
144+
expect(serialized).not.toContain("/Users/attacker");
145+
});
146+
});

src/telemetry/ai-observability.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Emits PostHog AI observability events ($ai_generation, $ai_span) from a
2+
// completed turn, in the same privacy mode as product telemetry: only ids,
3+
// enums, and counts ever leave the process. TurnContext already carries tool
4+
// call arguments and results for lifecycle hooks — this module reads only
5+
// the scalar/id fields off it and never the content fields.
6+
7+
import type { TurnContext } from "../session/hooks.js";
8+
import { getSessionId, type AiSpanKind, type Telemetry } from "./index.js";
9+
10+
// Turn ids are derived from the existing session id and the runtime's own
11+
// turn index rather than a freshly minted uuid per turn, so the trace id is
12+
// reproducible from data the runtime already tracks (per CL-5723: reuse an
13+
// existing turn id before inventing a new one).
14+
export function turnTraceId(turnIndex: number, sessionId: string = getSessionId()): string {
15+
return `${sessionId}:turn:${turnIndex}`;
16+
}
17+
18+
// Maps a tool call to a fixed span kind. Takes the tool's canonical name
19+
// (e.g. the subagent task tool's registered name) rather than reaching into
20+
// subagent internals, so this module has no dependency on tool
21+
// implementations beyond the one identifier it needs to classify.
22+
export function classifySpanKind(toolName: string, subagentToolName: string): AiSpanKind {
23+
return toolName === subagentToolName ? "subagent_call" : "tool_call";
24+
}
25+
26+
export type EmitAiObservabilityOptions = {
27+
// Name of the tool that spawns a sub-agent, used to classify that call's
28+
// span kind as "subagent_call" instead of the generic "tool_call".
29+
subagentToolName: string;
30+
};
31+
32+
// Called once per completed turn (the same onTurnComplete hook point
33+
// inference_turn already uses). Emits one $ai_generation for the model call,
34+
// then one $ai_span per tool call in the turn, all sharing the turn's
35+
// $ai_trace_id; each span's $ai_parent_id is the trace id since TurnContext
36+
// only tracks top-level tool calls (spans nested further, e.g. inside a
37+
// sub-agent's own turns, are that sub-agent process's own emission).
38+
export function emitAiObservability(
39+
telemetry: Telemetry,
40+
ctx: TurnContext,
41+
options: EmitAiObservabilityOptions,
42+
): void {
43+
const traceId = turnTraceId(ctx.turnIndex);
44+
45+
telemetry.capture("$ai_generation", {
46+
$ai_trace_id: traceId,
47+
provider_id: ctx.source.provider,
48+
model_id: ctx.source.model,
49+
input_tokens: ctx.usage.input,
50+
output_tokens: ctx.usage.output,
51+
cache_read_tokens: ctx.usage.cacheRead,
52+
cache_write_tokens: ctx.usage.cacheWrite,
53+
thinking_tokens: ctx.usage.thinking,
54+
duration_ms: ctx.durationMs,
55+
status: "ok",
56+
});
57+
58+
const resultsByCallId = new Map(ctx.toolResults.map((result) => [result.callId, result]));
59+
60+
for (const call of ctx.toolCalls) {
61+
const result = resultsByCallId.get(call.id);
62+
telemetry.capture("$ai_span", {
63+
$ai_trace_id: traceId,
64+
$ai_span_id: call.id,
65+
$ai_parent_id: traceId,
66+
span_kind: classifySpanKind(call.name, options.subagentToolName),
67+
status: result?.isError === true ? "error" : "ok",
68+
});
69+
}
70+
}

src/telemetry/index.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ const FLUSH_DEADLINE_MS = 500;
2626
export const TELEMETRY_NOTICE =
2727
"Anonymous usage telemetry is enabled (no prompts, code, or paths collected). Disable in /settings > Telemetry. Docs: docs/TELEMETRY.md";
2828

29-
export type TelemetryEvent = "cli_start" | "session_end" | "inference_turn";
29+
export type TelemetryEvent = "cli_start" | "session_end" | "inference_turn" | "$ai_generation" | "$ai_span";
30+
31+
// Fixed enum of AI observability span kinds. The raw tool name is never sent
32+
// as a property: an MCP tool name carries the server identifier it was
33+
// configured under (`mcp__<server>__<tool>`), which can be a local path or
34+
// otherwise identifying string. Callers map a tool call to one of these
35+
// kinds before capturing "$ai_span".
36+
export const AI_SPAN_KINDS = ["tool_call", "subagent_call"] as const;
37+
export type AiSpanKind = (typeof AI_SPAN_KINDS)[number];
3038

3139
// One id per interactive process (TUI session or CLI invocation), generated
3240
// once at module load and reused by every createTelemetry() instance for the
@@ -57,6 +65,25 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
5765
"thinking_tokens",
5866
"duration_ms",
5967
],
68+
// $ai_trace_id is the PostHog AI observability field that groups every
69+
// event from one turn into a trace. provider_id/model_id follow the same
70+
// canonical-id contract as inference_turn (never free-text provider names).
71+
$ai_generation: [
72+
"$ai_trace_id",
73+
"provider_id",
74+
"model_id",
75+
"input_tokens",
76+
"output_tokens",
77+
"cache_read_tokens",
78+
"cache_write_tokens",
79+
"thinking_tokens",
80+
"duration_ms",
81+
"status",
82+
],
83+
// $ai_parent_id is either the turn's $ai_trace_id (top-level tool call) or
84+
// another span's $ai_span_id (nested work). span_kind is one of
85+
// AI_SPAN_KINDS only -- never the raw tool name.
86+
$ai_span: ["$ai_trace_id", "$ai_span_id", "$ai_parent_id", "span_kind", "duration_ms", "status"],
6087
};
6188

6289
const FALSY_ENV_FLAG_VALUES = new Set(["", "0", "false", "off", "no"]);
@@ -142,7 +169,7 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry {
142169

143170
function capture(event: TelemetryEvent, properties?: Record<string, unknown>): void {
144171
if (!enabled) return;
145-
if (!(event === "cli_start" || event === "session_end" || event === "inference_turn")) return;
172+
if (!(event in EVENT_PROPERTY_ALLOWLIST)) return;
146173

147174
const body = {
148175
api_key: apiKey,

src/tui/runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
} from "./commands/registry.js";
9494
import { registerBuiltInCommands } from "./commands/built-in.js";
9595
import type { PluginModule } from "../plugins/loader.js";
96+
import { emitAiObservability } from "../telemetry/ai-observability.js";
9697
import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js";
9798
import { TELEMETRY_NOTICE } from "../telemetry/index.js";
9899
import { getTelemetry, setTelemetry } from "../telemetry/singleton.js";
@@ -115,7 +116,7 @@ import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
115116
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
116117
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
117118
import { promptSessionModeIfUnset } from "./session-mode-prompt.js";
118-
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
119+
import { createSubAgentSessionStore, taskToolDefinition, type SubAgentProvider } from "../subagent/index.js";
119120
import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/types/runtime";
120121
import { createSessionOperationQueue } from "./session-operation-queue.js";
121122
import { setAgentSourceUnlessClosed } from "./agent-source-sync.js";
@@ -1416,6 +1417,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14161417
thinking_tokens: ctx.usage.thinking,
14171418
duration_ms: ctx.durationMs,
14181419
});
1420+
emitAiObservability(getTelemetry(), ctx, { subagentToolName: taskToolDefinition.name });
14191421
},
14201422
// persistRunSnapshot is defined below but not invoked until the stream
14211423
// starts consuming events, well after this closure captures it.

tests/unit/telemetry.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,78 @@ test("capture strips properties not in inference_turn's allowlist", async () =>
172172
expect(body.properties.prompt).toBeUndefined();
173173
});
174174

175+
test("capture strips properties not in $ai_generation's allowlist", async () => {
176+
const calls: unknown[] = [];
177+
const impl = ((_url: string, init: RequestInit) => {
178+
calls.push(JSON.parse(init.body as string));
179+
return Promise.resolve(new Response("1", { status: 200 }));
180+
}) as unknown as typeof fetch;
181+
const telemetry = createTelemetry({
182+
settings: settingsWith("id"),
183+
env: {},
184+
fetchFn: impl,
185+
apiKey: "test-key",
186+
});
187+
telemetry.capture("$ai_generation", {
188+
$ai_trace_id: "trace-1",
189+
provider_id: "anthropic",
190+
model_id: "claude-x",
191+
input_tokens: 10,
192+
output_tokens: 20,
193+
cache_read_tokens: 1,
194+
cache_write_tokens: 2,
195+
thinking_tokens: 3,
196+
duration_ms: 400,
197+
status: "ok",
198+
prompt: "should-not-appear",
199+
completion: "should-not-appear",
200+
});
201+
await new Promise((resolve) => setTimeout(resolve, 0));
202+
expect(calls.length).toBe(1);
203+
const body = calls[0] as { event: string; properties: Record<string, unknown> };
204+
expect(body.event).toBe("$ai_generation");
205+
expect(body.properties.$ai_trace_id).toBe("trace-1");
206+
expect(body.properties.provider_id).toBe("anthropic");
207+
expect(body.properties.duration_ms).toBe(400);
208+
expect(body.properties.prompt).toBeUndefined();
209+
expect(body.properties.completion).toBeUndefined();
210+
});
211+
212+
test("capture strips properties not in $ai_span's allowlist, including raw tool name and args", async () => {
213+
const calls: unknown[] = [];
214+
const impl = ((_url: string, init: RequestInit) => {
215+
calls.push(JSON.parse(init.body as string));
216+
return Promise.resolve(new Response("1", { status: 200 }));
217+
}) as unknown as typeof fetch;
218+
const telemetry = createTelemetry({
219+
settings: settingsWith("id"),
220+
env: {},
221+
fetchFn: impl,
222+
apiKey: "test-key",
223+
});
224+
telemetry.capture("$ai_span", {
225+
$ai_trace_id: "trace-1",
226+
$ai_span_id: "span-1",
227+
$ai_parent_id: "trace-1",
228+
span_kind: "tool_call",
229+
status: "ok",
230+
tool_name: "mcp__acme__fetch_secret",
231+
tool_arguments: { path: "/etc/passwd" },
232+
tool_result: "should-not-appear",
233+
});
234+
await new Promise((resolve) => setTimeout(resolve, 0));
235+
expect(calls.length).toBe(1);
236+
const body = calls[0] as { event: string; properties: Record<string, unknown> };
237+
expect(body.event).toBe("$ai_span");
238+
expect(body.properties.$ai_trace_id).toBe("trace-1");
239+
expect(body.properties.$ai_span_id).toBe("span-1");
240+
expect(body.properties.$ai_parent_id).toBe("trace-1");
241+
expect(body.properties.span_kind).toBe("tool_call");
242+
expect(body.properties.tool_name).toBeUndefined();
243+
expect(body.properties.tool_arguments).toBeUndefined();
244+
expect(body.properties.tool_result).toBeUndefined();
245+
});
246+
175247
test("capture payload shape includes distinct_id and common props, with no client-side geoip flag", async () => {
176248
const calls: unknown[] = [];
177249
const impl = ((_url: string, init: RequestInit) => {

0 commit comments

Comments
 (0)