Skip to content

Commit 36521a7

Browse files
committed
Attribute failed turns from runtime source events
1 parent cab82ff commit 36521a7

5 files changed

Lines changed: 56 additions & 30 deletions

File tree

docs/TELEMETRY.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ Pre-progress operator aborts settle with `status=cancelled` and
114114
keeps a worker resumable settles with `status=interrupted` and the same
115115
`stop_reason=cancelled`; terminal events never report a still-running status.
116116

117-
A deterministic representative fixture uses 10 parent turns with 80 parent tool
118-
calls and 4 workers totaling 24 turns and 96 tool calls. The former per-call and
119-
worker-generation shape is 218 billable events; the default aggregate shape is
120-
18 (10 generations plus 4 start/end pairs), a 91.7% reduction. This is a test
121-
fixture, not a claim about production PostHog traffic.
117+
A deterministic synthetic fixture captures 10 parent generations, 80 parent
118+
tool spans, and 4 worker start/end pairs. The comparable former shape is 98
119+
billable events; the default aggregate shape is 18 (10 generations plus 8
120+
start/end events), removing 80 of 98 events, or 81.6%. This is synthetic test
121+
evidence, not a claim about production PostHog traffic.
122122

123123
Successful `$ai_generation` events may be sampled with
124124
`CORBITS_TELEMETRY_GENERATION_SAMPLE_RATE` (a float in `0``1`, default `1.0`
@@ -165,10 +165,11 @@ Terminal generation settlement belongs to `src/session/run-sink.ts`.
165165
`inference.error` records only a pending attempt failure: retry success or a
166166
completed message run discards it. `inference.done` settles success and only
167167
then applies successful-generation sampling. A failed `message.run.ended`
168-
settles an unresolved turn once as an unsampled terminal failure, attributed to
169-
the provider/model snapshot from the latest `inference.start`. Therefore a
170-
parent turn emits at most one terminal `$ai_generation`, including retry and
171-
failover paths.
168+
settles an unresolved turn once as an unsampled terminal failure. Attribution
169+
uses the latest `inference.usage` source, the first lifecycle payload carrying
170+
the runtime-resolved provider/model pair for an attempt; it does not infer
171+
fallback from the externally selected source. Therefore a parent turn emits at
172+
most one terminal `$ai_generation`, including retry and failover paths.
172173

173174
## What's never collected
174175

src/session/run-sink.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,11 @@ describe("createRunSink", () => {
150150
flush: async () => {},
151151
discard: () => {},
152152
};
153-
let source = { provider: "provider-a", model: "model-a" };
153+
const selectedSource = { provider: "provider-a", model: "model-a" };
154154
const observer = createTurnObserver({
155155
telemetry: () => telemetry,
156156
getSessionId: () => "session-1",
157-
getSource: () => source,
157+
getSource: () => selectedSource,
158158
});
159159
const runSink = createRunSink({
160160
emitter: new EventEmitter(),
@@ -164,10 +164,14 @@ describe("createRunSink", () => {
164164

165165
runSink.sink(event("inference.start", { model: "model-a" }));
166166
runSink.sink(event("inference.error", { error: { message: "attempt a failed" } }));
167-
source = { provider: "provider-b", model: "model-b" };
168167
runSink.sink(event("inference.start", { model: "model-b" }));
168+
runSink.sink(
169+
event("inference.usage", {
170+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
171+
source: { sourceId: "fallback", provider: "provider-b", model: "model-b" },
172+
}),
173+
);
169174
runSink.sink(event("inference.error", { error: { message: "attempt b failed" } }));
170-
source = { provider: "provider-a", model: "model-a" };
171175
runSink.sink(
172176
event("message.run.ended", {
173177
messageRunId: "run-1",

src/session/run-sink.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { EventEmitter } from "node:events";
22
import type { ReactorEmittedEvent } from "@intx/inference";
3-
import type { TokenUsage } from "@intx/types/runtime";
3+
import type { LastCycleSource, TokenUsage } from "@intx/types/runtime";
44
import { createPerfReactorObserver } from "../perf/reactor-spans.js";
55
import { onTurnBoundary } from "../agent/reactor-events.js";
66
import { createTurnContextCollector, type LifecycleHookManager, type RunSummary } from "./hooks.js";
@@ -26,6 +26,10 @@ export interface RunSinkArgs {
2626
// current count (the in-flight turn that has not completed yet) — used to
2727
// stamp parent_trace_id on subagent_end while tools still run.
2828
onTurnStarted?: (info: { turnIndex: number }) => void;
29+
// inference.usage is the first attempt event carrying the runtime-resolved
30+
// provider/model pair. It remains authoritative even when the selected source
31+
// outside the reactor has not changed during fallback.
32+
onTurnSourceObserved?: (info: { turnIndex: number; source: LastCycleSource }) => void;
2933
// Continues a resumed session's persisted run.json turn count instead of
3034
// restarting the collector at zero.
3135
initialTurnCount?: number;
@@ -94,6 +98,7 @@ export function createRunSink(args: RunSinkArgs): RunSink {
9498
onTurnComplete,
9599
onTurnFailed,
96100
onTurnStarted,
101+
onTurnSourceObserved,
97102
initialTurnCount,
98103
onTurnBoundarySnapshot,
99104
} = args;
@@ -147,6 +152,12 @@ export function createRunSink(args: RunSinkArgs): RunSink {
147152
turnInFlight = true;
148153
onTurnStarted?.({ turnIndex: turnCollector.getTurnCount() });
149154
}
155+
if (event.type === "inference.usage") {
156+
onTurnSourceObserved?.({
157+
turnIndex: turnCollector.getTurnCount(),
158+
source: event.data.source,
159+
});
160+
}
150161

151162
if (event.type === "reactor.done") {
152163
runCompleted = true;

src/telemetry/ai-observability.test.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,28 @@ describe("turnTraceId", () => {
146146
});
147147

148148
describe("representative fleet event volume", () => {
149-
test("reduces deterministic billable events by at least 80 percent", () => {
150-
const parentTurns = 10;
151-
const parentToolCalls = 80;
152-
const workers = 4;
153-
const workerTurns = 24;
154-
const workerToolCalls = 96;
155-
const oldBillableEvents =
156-
parentTurns + parentToolCalls + workerTurns + workerToolCalls + workers * 2;
157-
const newBillableEvents = parentTurns + workers * 2;
158-
159-
expect({ oldBillableEvents, newBillableEvents }).toEqual({
160-
oldBillableEvents: 218,
161-
newBillableEvents: 18,
162-
});
163-
expect(1 - newBillableEvents / oldBillableEvents).toBeGreaterThanOrEqual(0.8);
149+
test("reduces deterministic synthetic billable events by at least 80 percent", () => {
150+
const captureFixture = (includeToolSpans: boolean): string[] => {
151+
const captured: string[] = [];
152+
for (let turn = 0; turn < 10; turn++) captured.push("$ai_generation");
153+
if (includeToolSpans) {
154+
for (let toolCall = 0; toolCall < 80; toolCall++) captured.push("$ai_span");
155+
}
156+
for (let worker = 0; worker < 4; worker++) {
157+
captured.push("subagent_start", "subagent_end");
158+
}
159+
return captured;
160+
};
161+
162+
const oldCaptured = captureFixture(true);
163+
const newCaptured = captureFixture(false);
164+
const reduction = 1 - newCaptured.length / oldCaptured.length;
165+
166+
expect(oldCaptured).toHaveLength(98);
167+
expect(newCaptured).toHaveLength(18);
168+
expect(oldCaptured.length - newCaptured.length).toBe(80);
169+
expect(reduction).toBeCloseTo(80 / 98, 6);
170+
expect(reduction).toBeGreaterThanOrEqual(0.8);
164171
});
165172
});
166173

src/telemetry/ai-observability.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,18 @@ export interface CreateTurnObserverOptions {
229229
// one place instead of at each call site.
230230
export function createTurnObserver(options: CreateTurnObserverOptions): {
231231
onTurnStarted: (info: { turnIndex: number }) => void;
232+
onTurnSourceObserved: (info: { turnIndex: number; source: TurnSource }) => void;
232233
onTurnComplete: (ctx: TurnContext) => void;
233234
onTurnFailed: (info: { turnIndex: number; error: string }) => void;
234235
} {
235236
let latestAttemptSource: TurnSource | undefined;
236237
return {
237238
onTurnStarted: (info) => {
238-
latestAttemptSource = { ...options.getSource() };
239239
noteCurrentTurnTraceId(turnTraceId(options.getSessionId(), info.turnIndex));
240240
},
241+
onTurnSourceObserved: (info) => {
242+
latestAttemptSource = { ...info.source };
243+
},
241244
onTurnComplete: (ctx) => {
242245
latestAttemptSource = undefined;
243246
clearCurrentTurnTraceId();

0 commit comments

Comments
 (0)