@narumitw/pi-langfuse 0.50.5 → 0.50.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tracing.ts CHANGED
@@ -6,948 +6,895 @@ export const TRACE_SCHEMA_VERSION = "2";
6
6
  const MAX_HEADER_VALUE_LENGTH = 1_024;
7
7
  const CONTENT_DISABLED = "[content capture disabled]";
8
8
  const ALLOWED_RESPONSE_HEADERS = new Set([
9
- "anthropic-ratelimit-requests-limit",
10
- "anthropic-ratelimit-requests-remaining",
11
- "anthropic-ratelimit-requests-reset",
12
- "anthropic-ratelimit-tokens-limit",
13
- "anthropic-ratelimit-tokens-remaining",
14
- "anthropic-ratelimit-tokens-reset",
15
- "cf-ray",
16
- "openai-request-id",
17
- "request-id",
18
- "retry-after",
19
- "x-amzn-requestid",
20
- "x-request-id",
21
- "x-ratelimit-limit-requests",
22
- "x-ratelimit-limit-tokens",
23
- "x-ratelimit-remaining-requests",
24
- "x-ratelimit-remaining-tokens",
25
- "x-ratelimit-reset-requests",
26
- "x-ratelimit-reset-tokens",
9
+ "anthropic-ratelimit-requests-limit",
10
+ "anthropic-ratelimit-requests-remaining",
11
+ "anthropic-ratelimit-requests-reset",
12
+ "anthropic-ratelimit-tokens-limit",
13
+ "anthropic-ratelimit-tokens-remaining",
14
+ "anthropic-ratelimit-tokens-reset",
15
+ "cf-ray",
16
+ "openai-request-id",
17
+ "request-id",
18
+ "retry-after",
19
+ "x-amzn-requestid",
20
+ "x-request-id",
21
+ "x-ratelimit-limit-requests",
22
+ "x-ratelimit-limit-tokens",
23
+ "x-ratelimit-remaining-requests",
24
+ "x-ratelimit-remaining-tokens",
25
+ "x-ratelimit-reset-requests",
26
+ "x-ratelimit-reset-tokens",
27
27
  ]);
28
28
 
29
29
  export interface ObservationAttributes {
30
- input?: unknown;
31
- output?: unknown;
32
- metadata?: Record<string, unknown>;
33
- level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
34
- statusMessage?: string;
35
- completionStartTime?: Date;
36
- model?: string;
37
- modelParameters?: Record<string, string | number>;
38
- usageDetails?: Record<string, number>;
39
- costDetails?: Record<string, number>;
40
- version?: string;
41
- name?: string;
42
- sessionId?: string;
43
- userId?: string;
44
- tags?: string[];
30
+ input?: unknown;
31
+ output?: unknown;
32
+ metadata?: Record<string, unknown>;
33
+ level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
34
+ statusMessage?: string;
35
+ completionStartTime?: Date;
36
+ model?: string;
37
+ modelParameters?: Record<string, string | number>;
38
+ usageDetails?: Record<string, number>;
39
+ costDetails?: Record<string, number>;
40
+ version?: string;
41
+ name?: string;
42
+ sessionId?: string;
43
+ userId?: string;
44
+ tags?: string[];
45
45
  }
46
46
 
47
47
  export interface Observation {
48
- update(attributes: ObservationAttributes): Observation;
49
- updateTrace?(attributes: ObservationAttributes): Observation;
50
- end(endTime?: number): Observation;
48
+ update(attributes: ObservationAttributes): Observation;
49
+ updateTrace?(attributes: ObservationAttributes): Observation;
50
+ end(endTime?: number): Observation;
51
51
  }
52
52
 
53
53
  export type ObservationType = "agent" | "generation" | "span" | "tool";
54
54
 
55
55
  export interface TraceBackend {
56
- start(
57
- name: string,
58
- attributes: ObservationAttributes,
59
- options: { asType: ObservationType; parent?: Observation },
60
- ): Observation;
61
- forceFlush(): Promise<void>;
62
- shutdown(): Promise<void>;
56
+ start(
57
+ name: string,
58
+ attributes: ObservationAttributes,
59
+ options: { asType: ObservationType; parent?: Observation },
60
+ ): Observation;
61
+ forceFlush(): Promise<void>;
62
+ shutdown(): Promise<void>;
63
63
  }
64
64
 
65
65
  interface RecorderContext {
66
- sessionId: string;
67
- userId?: string;
68
- cwd: string;
69
- mode: string;
70
- captureContent: boolean;
66
+ sessionId: string;
67
+ userId?: string;
68
+ cwd: string;
69
+ mode: string;
70
+ captureContent: boolean;
71
71
  }
72
72
 
73
73
  interface ModelDescriptor {
74
- provider?: string;
75
- id?: string;
76
- api?: string;
74
+ provider?: string;
75
+ id?: string;
76
+ api?: string;
77
77
  }
78
78
 
79
79
  export interface GitMetadata {
80
- branch?: string;
81
- commit?: string;
82
- detached: boolean;
80
+ branch?: string;
81
+ commit?: string;
82
+ detached: boolean;
83
83
  }
84
84
 
85
85
  export interface ContextSnapshot {
86
- leafId?: string | null;
87
- contextUsage?: {
88
- tokens: number | null;
89
- contextWindow: number;
90
- percent: number | null;
91
- };
86
+ leafId?: string | null;
87
+ contextUsage?: {
88
+ tokens: number | null;
89
+ contextWindow: number;
90
+ percent: number | null;
91
+ };
92
92
  }
93
93
 
94
94
  interface BeginAgentInput {
95
- prompt: unknown;
96
- images?: unknown;
97
- model?: ModelDescriptor;
98
- git?: GitMetadata;
99
- snapshot?: ContextSnapshot;
95
+ prompt: unknown;
96
+ images?: unknown;
97
+ model?: ModelDescriptor;
98
+ git?: GitMetadata;
99
+ snapshot?: ContextSnapshot;
100
100
  }
101
101
 
102
102
  interface AttemptInput {
103
- reason?: string;
103
+ reason?: string;
104
104
  }
105
105
 
106
106
  interface GenerationInput {
107
- payload?: unknown;
108
- payloadStage?: "before_provider_request";
109
- model?: ModelDescriptor;
110
- thinkingLevel?: string;
107
+ payload?: unknown;
108
+ payloadStage?: "before_provider_request";
109
+ model?: ModelDescriptor;
110
+ thinkingLevel?: string;
111
111
  }
112
112
 
113
113
  interface AssistantMessage {
114
- role: string;
115
- content?: unknown;
116
- provider?: string;
117
- api?: string;
118
- model?: string;
119
- responseModel?: string;
120
- responseId?: string;
121
- usage?: {
122
- input?: number;
123
- output?: number;
124
- cacheRead?: number;
125
- cacheWrite?: number;
126
- cacheWrite1h?: number;
127
- reasoning?: number;
128
- totalTokens?: number;
129
- cost?: {
130
- input?: number;
131
- output?: number;
132
- cacheRead?: number;
133
- cacheWrite?: number;
134
- total?: number;
135
- };
136
- };
137
- stopReason?: string;
138
- errorMessage?: string;
114
+ role: string;
115
+ content?: unknown;
116
+ provider?: string;
117
+ api?: string;
118
+ model?: string;
119
+ responseModel?: string;
120
+ responseId?: string;
121
+ usage?: {
122
+ input?: number;
123
+ output?: number;
124
+ cacheRead?: number;
125
+ cacheWrite?: number;
126
+ cacheWrite1h?: number;
127
+ reasoning?: number;
128
+ totalTokens?: number;
129
+ cost?: {
130
+ input?: number;
131
+ output?: number;
132
+ cacheRead?: number;
133
+ cacheWrite?: number;
134
+ total?: number;
135
+ };
136
+ };
137
+ stopReason?: string;
138
+ errorMessage?: string;
139
139
  }
140
140
 
141
141
  interface ToolResult {
142
- content?: unknown;
143
- details?: unknown;
144
- isError?: boolean;
142
+ content?: unknown;
143
+ details?: unknown;
144
+ isError?: boolean;
145
145
  }
146
146
 
147
147
  interface TurnResult {
148
- message: { role: string; stopReason?: string; errorMessage?: string };
149
- toolResultCount: number;
148
+ message: { role: string; stopReason?: string; errorMessage?: string };
149
+ toolResultCount: number;
150
150
  }
151
151
 
152
152
  interface CompactionStart {
153
- reason: string;
154
- willRetry: boolean;
155
- tokensBefore?: number;
156
- messagesToSummarize: number;
157
- turnPrefixMessages: number;
158
- branchEntries: number;
159
- isSplitTurn: boolean;
153
+ reason: string;
154
+ willRetry: boolean;
155
+ tokensBefore?: number;
156
+ messagesToSummarize: number;
157
+ turnPrefixMessages: number;
158
+ branchEntries: number;
159
+ isSplitTurn: boolean;
160
160
  }
161
161
 
162
162
  interface CompactionFinish {
163
- reason: string;
164
- willRetry: boolean;
165
- fromExtension: boolean;
166
- tokensBefore?: number;
167
- details?: unknown;
168
- usage?: {
169
- input?: number;
170
- output?: number;
171
- cacheRead?: number;
172
- cacheWrite?: number;
173
- totalTokens?: number;
174
- cost?: {
175
- input?: number;
176
- output?: number;
177
- cacheRead?: number;
178
- cacheWrite?: number;
179
- total?: number;
180
- };
181
- };
163
+ reason: string;
164
+ willRetry: boolean;
165
+ fromExtension: boolean;
166
+ tokensBefore?: number;
167
+ details?: unknown;
168
+ usage?: {
169
+ input?: number;
170
+ output?: number;
171
+ cacheRead?: number;
172
+ cacheWrite?: number;
173
+ totalTokens?: number;
174
+ cost?: {
175
+ input?: number;
176
+ output?: number;
177
+ cacheRead?: number;
178
+ cacheWrite?: number;
179
+ total?: number;
180
+ };
181
+ };
182
182
  }
183
183
 
184
184
  type Outcome = "success" | "error" | "aborted" | "length" | "interrupted";
185
185
 
186
186
  interface GenerationState {
187
- observation: Observation;
188
- endTime?: number;
189
- completionStartTime?: number;
190
- requestMetadata: Record<string, unknown>;
191
- statuses: number[];
192
- headers: Record<string, string>;
187
+ observation: Observation;
188
+ endTime?: number;
189
+ completionStartTime?: number;
190
+ requestMetadata: Record<string, unknown>;
191
+ statuses: number[];
192
+ headers: Record<string, string>;
193
193
  }
194
194
 
195
195
  interface ToolState {
196
- observation: Observation;
197
- startedAt: number;
198
- progressUpdates: number;
199
- firstProgressAt?: number;
196
+ observation: Observation;
197
+ startedAt: number;
198
+ progressUpdates: number;
199
+ firstProgressAt?: number;
200
200
  }
201
201
 
202
202
  interface CompactionState {
203
- observation: Observation;
203
+ observation: Observation;
204
204
  }
205
205
 
206
206
  interface Counters {
207
- attempts: number;
208
- turns: number;
209
- generations: number;
210
- tools: number;
211
- toolErrors: number;
212
- compactions: number;
213
- recoveredErrors: number;
214
- failedAttempts: number;
207
+ attempts: number;
208
+ turns: number;
209
+ generations: number;
210
+ tools: number;
211
+ toolErrors: number;
212
+ compactions: number;
213
+ recoveredErrors: number;
214
+ failedAttempts: number;
215
215
  }
216
216
 
217
217
  export class TraceRecorder {
218
- private root: Observation | undefined;
219
- private attempt: Observation | undefined;
220
- private attemptIndex: number | undefined;
221
- private turn: Observation | undefined;
222
- private turnIndex: number | undefined;
223
- private generation: GenerationState | undefined;
224
- private readonly tools = new Map<string, ToolState>();
225
- private readonly duplicateToolIds = new Set<string>();
226
- private compaction: CompactionState | undefined;
227
- private lastOutput: unknown;
228
- private lastAssistant: AssistantMessage | undefined;
229
- private lastAttemptOutcome: Outcome | undefined;
230
- private unresolvedToolErrors = 0;
231
- private startSnapshot: ContextSnapshot | undefined;
232
- private counters: Counters = emptyCounters();
233
-
234
- constructor(
235
- private readonly backend: TraceBackend,
236
- private readonly context: RecorderContext,
237
- ) {}
238
-
239
- private startObservation(
240
- name: string,
241
- attributes: ObservationAttributes,
242
- options: { asType: ObservationType; parent?: Observation },
243
- ): Observation {
244
- const { sessionId, userId } = this.context;
245
- return this.backend.start(
246
- name,
247
- { ...attributes, sessionId, ...(userId ? { userId } : {}) },
248
- options,
249
- );
250
- }
251
-
252
- hasActiveTrace(): boolean {
253
- return this.root !== undefined;
254
- }
255
-
256
- hasActiveAttempt(): boolean {
257
- return this.attempt !== undefined;
258
- }
259
-
260
- beginAgent(input: BeginAgentInput): void {
261
- if (this.root) this.closeActiveTrace("Interrupted by a new Pi run.", undefined, "interrupted");
262
-
263
- this.lastOutput = undefined;
264
- this.lastAssistant = undefined;
265
- this.lastAttemptOutcome = undefined;
266
- this.unresolvedToolErrors = 0;
267
- this.duplicateToolIds.clear();
268
- this.startSnapshot = input.snapshot;
269
- this.counters = emptyCounters();
270
- const traceInput = this.capture({
271
- prompt: input.prompt,
272
- ...(hasItems(input.images) ? { images: input.images } : {}),
273
- });
274
- const metadata: Record<string, unknown> = {
275
- "pi.cwd": this.context.cwd,
276
- ...(input.git?.branch ? { "pi.git.branch": input.git.branch } : {}),
277
- ...(input.git?.commit ? { "pi.git.commit": input.git.commit } : {}),
278
- ...(input.git ? { "pi.git.detached": input.git.detached } : {}),
279
- "pi.mode": this.context.mode,
280
- ...(input.model?.id ? { "pi.model": input.model.id } : {}),
281
- ...(input.model?.provider ? { "pi.provider": input.model.provider } : {}),
282
- "pi.session.id": this.context.sessionId,
283
- "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
284
- ...snapshotMetadata("start", input.snapshot),
285
- };
286
- const attributes = { input: traceInput, metadata, version: TRACE_SCHEMA_VERSION };
287
- const gitTag = input.git?.branch
288
- ? `branch:${input.git.branch}`
289
- : input.git?.detached
290
- ? "git:detached"
291
- : undefined;
292
-
293
- this.root = this.startObservation("pi.agent", attributes, { asType: "agent" });
294
- this.root.updateTrace?.({
295
- name: "pi.trace",
296
- sessionId: this.context.sessionId,
297
- ...(this.context.userId ? { userId: this.context.userId } : {}),
298
- version: TRACE_SCHEMA_VERSION,
299
- input: traceInput,
300
- metadata,
301
- tags: ["pi", ...(gitTag ? [gitTag] : [])],
302
- });
303
- }
304
-
305
- beginAttempt(input: AttemptInput = {}): void {
306
- if (!this.root) return;
307
- if (this.attempt) {
308
- this.closeAttempt(undefined, "Interrupted by the next Pi attempt.", "interrupted");
309
- }
310
- const index = this.counters.attempts;
311
- this.counters.attempts += 1;
312
- this.lastAssistant = undefined;
313
- this.unresolvedToolErrors = 0;
314
- this.attemptIndex = index;
315
- this.attempt = this.startObservation(
316
- "pi.attempt",
317
- {
318
- metadata: {
319
- "pi.attempt.index": index,
320
- ...(input.reason ? { "pi.attempt.reason": input.reason } : {}),
321
- },
322
- version: TRACE_SCHEMA_VERSION,
323
- },
324
- { asType: "span", parent: this.root },
325
- );
326
- }
327
-
328
- finishAttempt(message?: AssistantMessage): void {
329
- if (message?.role === "assistant") this.finishAssistant(message);
330
- if (!this.attempt) return;
331
- const outcome = this.unresolvedToolErrors > 0 ? "error" : classifyOutcome(message);
332
- this.closeAttempt(
333
- message,
334
- this.unresolvedToolErrors > 0 ? "Pi attempt ended after a tool failure." : undefined,
335
- outcome,
336
- );
337
- }
338
-
339
- beginTurn(turnIndex: number): void {
340
- if (!this.root) return;
341
- if (this.turn) this.closeTurn("Interrupted by the next Pi turn.", "ERROR");
342
- this.counters.turns += 1;
343
- this.turnIndex = turnIndex;
344
- this.turn = this.startObservation(
345
- "pi.turn",
346
- { metadata: { "pi.turn.index": turnIndex }, version: TRACE_SCHEMA_VERSION },
347
- { asType: "span", parent: this.attempt ?? this.root },
348
- );
349
- }
350
-
351
- finishTurn(turnIndex: number, result: TurnResult): void {
352
- if (!this.turn) return;
353
- this.closeGeneration("Turn ended without a finalized assistant message.");
354
- this.closeTools("Tool span ended when the Pi turn finished.");
355
-
356
- const mismatched = this.turnIndex !== turnIndex;
357
- const outcome = classifyOutcome(result.message);
358
- this.turn.update({
359
- metadata: {
360
- "pi.turn.index": this.turnIndex ?? turnIndex,
361
- "pi.turn.tool_result_count": result.toolResultCount,
362
- ...(result.message.stopReason ? { "pi.turn.stop_reason": result.message.stopReason } : {}),
363
- },
364
- ...(mismatched && outcome === "success"
365
- ? {
366
- level: "WARNING" as const,
367
- statusMessage: `Turn ${turnIndex} ended while turn ${this.turnIndex} was active.`,
368
- }
369
- : severityForOutcome(outcome, result.message.errorMessage)),
370
- });
371
- this.turn.end();
372
- this.turn = undefined;
373
- this.turnIndex = undefined;
374
- }
375
-
376
- beginGeneration(input: GenerationInput = {}): void {
377
- if (!this.root) return;
378
- this.closeGeneration("Interrupted by the next provider request.");
379
- if (this.unresolvedToolErrors > 0) {
380
- this.counters.recoveredErrors += this.unresolvedToolErrors;
381
- this.unresolvedToolErrors = 0;
382
- }
383
- this.counters.generations += 1;
384
- const requestMetadata: Record<string, unknown> = {
385
- ...(input.payloadStage ? { "pi.request.payload_stage": input.payloadStage } : {}),
386
- ...(input.model?.provider ? { "pi.request.provider": input.model.provider } : {}),
387
- ...(input.model?.id ? { "pi.request.model": input.model.id } : {}),
388
- ...(input.model?.api ? { "pi.request.api": input.model.api } : {}),
389
- ...(input.thinkingLevel ? { "pi.request.thinking_level": input.thinkingLevel } : {}),
390
- };
391
- const observation = this.startObservation(
392
- "pi.llm",
393
- {
394
- ...(input.payload !== undefined ? { input: this.capture(input.payload) } : {}),
395
- ...(input.model?.id ? { model: input.model.id } : {}),
396
- ...(input.thinkingLevel
397
- ? { modelParameters: { thinking_level: input.thinkingLevel } }
398
- : {}),
399
- ...(Object.keys(requestMetadata).length > 0 ? { metadata: requestMetadata } : {}),
400
- version: TRACE_SCHEMA_VERSION,
401
- },
402
- { asType: "generation", parent: this.turn ?? this.attempt ?? this.root },
403
- );
404
- this.generation = {
405
- observation,
406
- requestMetadata,
407
- statuses: [],
408
- headers: {},
409
- };
410
- }
411
-
412
- recordProviderResponse(status: number, headers: Record<string, string> = {}): void {
413
- if (!this.generation) return;
414
- this.generation.statuses.push(status);
415
- Object.assign(this.generation.headers, allowlistedResponseHeaders(headers));
416
- }
417
-
418
- markGenerationFirstOutput(timestamp = Date.now()): void {
419
- if (this.generation && this.generation.completionStartTime === undefined) {
420
- this.generation.completionStartTime = timestamp;
421
- }
422
- }
423
-
424
- markGenerationEnd(endTime = Date.now()): void {
425
- if (this.generation && this.generation.endTime === undefined) {
426
- this.generation.endTime = endTime;
427
- }
428
- }
429
-
430
- finishAssistant(message: AssistantMessage): void {
431
- if (message.role !== "assistant") return;
432
- this.lastAssistant = message;
433
- this.lastOutput = this.capture(message.content);
434
- if (!this.generation) return;
435
-
436
- const state = this.generation;
437
- const usageDetails = numericRecord({
438
- input: message.usage?.input,
439
- output: message.usage?.output,
440
- cache_read_input_tokens: message.usage?.cacheRead,
441
- cache_creation_input_tokens: message.usage?.cacheWrite,
442
- total: message.usage?.totalTokens,
443
- });
444
- const costDetails = positiveNumericRecord({
445
- input: message.usage?.cost?.input,
446
- output: message.usage?.cost?.output,
447
- cache_read: message.usage?.cost?.cacheRead,
448
- cache_write: message.usage?.cost?.cacheWrite,
449
- total: message.usage?.cost?.total,
450
- });
451
- const outcome = classifyOutcome(message);
452
- const responseModel = message.responseModel ?? message.model;
453
- const httpMetadata = generationHttpMetadata(state);
454
- const recoveredStatuses = state.statuses.filter((status) => status >= 400).length;
455
- if (outcome === "success" && recoveredStatuses > 0) {
456
- this.counters.recoveredErrors += recoveredStatuses;
457
- }
458
- state.observation.update({
459
- output: this.lastOutput,
460
- ...(state.completionStartTime !== undefined
461
- ? { completionStartTime: new Date(state.completionStartTime) }
462
- : {}),
463
- ...(responseModel ? { model: responseModel } : {}),
464
- ...(Object.keys(usageDetails).length > 0 ? { usageDetails } : {}),
465
- ...(Object.keys(costDetails).length > 0 ? { costDetails } : {}),
466
- metadata: {
467
- ...state.requestMetadata,
468
- ...httpMetadata,
469
- ...(message.provider
470
- ? { "pi.provider": message.provider, "pi.response.provider": message.provider }
471
- : {}),
472
- ...(message.api ? { "pi.response.api": message.api } : {}),
473
- ...(responseModel ? { "pi.response.model": responseModel } : {}),
474
- ...(message.responseId ? { "pi.response.id": message.responseId } : {}),
475
- ...(message.responseModel && message.model && message.responseModel !== message.model
476
- ? { "pi.requested_model": message.model }
477
- : {}),
478
- ...(message.stopReason ? { "pi.stop_reason": message.stopReason } : {}),
479
- ...(finiteNumber(message.usage?.reasoning)
480
- ? { "pi.usage.reasoning_tokens": message.usage.reasoning }
481
- : {}),
482
- ...(finiteNumber(message.usage?.cacheWrite1h)
483
- ? { "pi.usage.cache_write_1h_tokens": message.usage.cacheWrite1h }
484
- : {}),
485
- },
486
- ...severityForOutcome(outcome, message.errorMessage),
487
- });
488
- state.observation.end(state.endTime);
489
- this.generation = undefined;
490
- }
491
-
492
- beginTool(toolCallId: string, toolName: string, args?: unknown, startedAt = Date.now()): void {
493
- if (!this.root) return;
494
- this.counters.tools += 1;
495
- if (this.duplicateToolIds.has(toolCallId)) {
496
- this.counters.toolErrors += 1;
497
- const duplicate = this.startToolObservation(toolCallId, toolName, args);
498
- duplicate.update({
499
- level: "ERROR",
500
- statusMessage: "Duplicate tool call ID cannot be correlated safely.",
501
- });
502
- duplicate.end();
503
- return;
504
- }
505
-
506
- const existing = this.tools.get(toolCallId);
507
- if (existing) {
508
- existing.observation.update({
509
- level: "ERROR",
510
- statusMessage: "Duplicate tool execution started.",
511
- });
512
- existing.observation.end();
513
- this.tools.delete(toolCallId);
514
- this.counters.toolErrors += 2;
515
- const duplicate = this.startToolObservation(toolCallId, toolName, args);
516
- duplicate.update({
517
- level: "ERROR",
518
- statusMessage: "Duplicate tool call ID cannot be correlated safely.",
519
- });
520
- duplicate.end();
521
- this.duplicateToolIds.add(toolCallId);
522
- return;
523
- }
524
-
525
- this.tools.set(toolCallId, {
526
- observation: this.startToolObservation(toolCallId, toolName, args),
527
- startedAt,
528
- progressUpdates: 0,
529
- });
530
- }
531
-
532
- recordToolInput(toolCallId: string, input: unknown): void {
533
- if (input === undefined || this.duplicateToolIds.has(toolCallId)) return;
534
- this.tools.get(toolCallId)?.observation.update({ input: this.capture(input) });
535
- }
536
-
537
- recordToolProgress(toolCallId: string, timestamp = Date.now()): void {
538
- const tool = this.tools.get(toolCallId);
539
- if (!tool) return;
540
- tool.progressUpdates += 1;
541
- tool.firstProgressAt ??= timestamp;
542
- }
543
-
544
- finishTool(toolCallId: string, result: ToolResult): void {
545
- if (this.duplicateToolIds.has(toolCallId)) return;
546
- const tool = this.tools.get(toolCallId);
547
- if (!tool) return;
548
- if (result.isError) {
549
- this.counters.toolErrors += 1;
550
- this.unresolvedToolErrors += 1;
551
- }
552
- tool.observation.update({
553
- output: this.capture({ content: result.content, details: result.details }),
554
- metadata: {
555
- "pi.tool.progress_update_count": tool.progressUpdates,
556
- ...(tool.firstProgressAt !== undefined
557
- ? {
558
- "pi.tool.time_to_first_progress_ms": Math.max(
559
- 0,
560
- tool.firstProgressAt - tool.startedAt,
561
- ),
562
- }
563
- : {}),
564
- },
565
- ...(result.isError
566
- ? { level: "ERROR" as const, statusMessage: "Pi tool execution failed." }
567
- : {}),
568
- });
569
- tool.observation.end();
570
- this.tools.delete(toolCallId);
571
- }
572
-
573
- beginCompaction(input: CompactionStart): void {
574
- if (!this.root) return;
575
- this.closeCompaction("Interrupted by another Pi compaction.");
576
- this.counters.compactions += 1;
577
- this.compaction = {
578
- observation: this.startObservation(
579
- "pi.compaction",
580
- {
581
- metadata: {
582
- "pi.compaction.reason": input.reason,
583
- "pi.compaction.will_retry": input.willRetry,
584
- ...(finiteNumber(input.tokensBefore)
585
- ? { "pi.compaction.tokens_before": input.tokensBefore }
586
- : {}),
587
- "pi.compaction.messages_to_summarize": input.messagesToSummarize,
588
- "pi.compaction.turn_prefix_messages": input.turnPrefixMessages,
589
- "pi.compaction.branch_entries": input.branchEntries,
590
- "pi.compaction.is_split_turn": input.isSplitTurn,
591
- },
592
- version: TRACE_SCHEMA_VERSION,
593
- },
594
- { asType: "span", parent: this.root },
595
- ),
596
- };
597
- }
598
-
599
- finishCompaction(input: CompactionFinish): void {
600
- if (!this.compaction) return;
601
- const details = structuralCompactionDetails(input.details);
602
- const usage = numericRecord({
603
- input: input.usage?.input,
604
- output: input.usage?.output,
605
- cache_read: input.usage?.cacheRead,
606
- cache_write: input.usage?.cacheWrite,
607
- total: input.usage?.totalTokens,
608
- });
609
- const cost = positiveNumericRecord({
610
- input: input.usage?.cost?.input,
611
- output: input.usage?.cost?.output,
612
- cache_read: input.usage?.cost?.cacheRead,
613
- cache_write: input.usage?.cost?.cacheWrite,
614
- total: input.usage?.cost?.total,
615
- });
616
- this.compaction.observation.update({
617
- metadata: {
618
- "pi.compaction.reason": input.reason,
619
- "pi.compaction.will_retry": input.willRetry,
620
- "pi.compaction.from_extension": input.fromExtension,
621
- ...(finiteNumber(input.tokensBefore)
622
- ? { "pi.compaction.tokens_before": input.tokensBefore }
623
- : {}),
624
- ...details,
625
- ...prefixRecord("pi.compaction.usage", usage),
626
- ...prefixRecord("pi.compaction.cost", cost),
627
- },
628
- });
629
- this.compaction.observation.end();
630
- this.compaction = undefined;
631
- }
632
-
633
- settle(snapshot?: ContextSnapshot): void {
634
- this.closeActiveTrace(undefined, snapshot);
635
- }
636
-
637
- interrupt(statusMessage: string, snapshot?: ContextSnapshot): void {
638
- this.closeActiveTrace(statusMessage, snapshot, "interrupted");
639
- }
640
-
641
- async flush(): Promise<void> {
642
- await this.backend.forceFlush();
643
- }
644
-
645
- async shutdown(snapshot?: ContextSnapshot): Promise<void> {
646
- this.closeActiveTrace("Pi shut down before the active trace settled.", snapshot, "interrupted");
647
- await this.backend.shutdown();
648
- }
649
-
650
- private closeActiveTrace(
651
- statusMessage?: string,
652
- snapshot?: ContextSnapshot,
653
- forcedOutcome?: Outcome,
654
- ): void {
655
- this.closeTurn(statusMessage ?? "Pi turn ended when the agent run settled.");
656
- this.closeAttempt(undefined, statusMessage, forcedOutcome ?? "interrupted");
657
- this.closeCompaction(statusMessage ?? "Pi compaction ended when the agent run settled.");
658
-
659
- if (!this.root) return;
660
- const baseOutcome =
661
- forcedOutcome ?? this.lastAttemptOutcome ?? classifyOutcome(this.lastAssistant);
662
- const recoveredErrorCount =
663
- this.counters.recoveredErrors +
664
- (baseOutcome === "success" ? this.counters.failedAttempts : 0);
665
- const traceOutcome =
666
- baseOutcome === "success" && recoveredErrorCount > 0 ? "recovered_success" : baseOutcome;
667
- const finalMetadata: Record<string, unknown> = {
668
- "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
669
- "pi.trace.outcome": traceOutcome,
670
- ...(this.lastAssistant?.stopReason
671
- ? { "pi.trace.stop_reason": this.lastAssistant.stopReason }
672
- : {}),
673
- "pi.trace.attempt_count": this.counters.attempts,
674
- "pi.trace.turn_count": this.counters.turns,
675
- "pi.trace.generation_count": this.counters.generations,
676
- "pi.trace.tool_count": this.counters.tools,
677
- "pi.trace.tool_error_count": this.counters.toolErrors,
678
- "pi.trace.compaction_count": this.counters.compactions,
679
- "pi.trace.recovered_error_count": recoveredErrorCount,
680
- ...snapshotMetadata("start", this.startSnapshot),
681
- ...snapshotMetadata("end", snapshot),
682
- };
683
- const finalAttributes: ObservationAttributes = {
684
- ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
685
- metadata: finalMetadata,
686
- ...severityForOutcome(baseOutcome, statusMessage ?? this.lastAssistant?.errorMessage),
687
- };
688
- this.root.update(finalAttributes);
689
- this.root.updateTrace?.({
690
- ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
691
- metadata: finalMetadata,
692
- version: TRACE_SCHEMA_VERSION,
693
- });
694
- this.root.end();
695
- this.root = undefined;
696
- this.lastOutput = undefined;
697
- this.lastAssistant = undefined;
698
- this.lastAttemptOutcome = undefined;
699
- this.unresolvedToolErrors = 0;
700
- this.duplicateToolIds.clear();
701
- this.startSnapshot = undefined;
702
- }
703
-
704
- private closeAttempt(
705
- message?: AssistantMessage,
706
- statusMessage?: string,
707
- forcedOutcome?: Outcome,
708
- ): void {
709
- if (!this.attempt) return;
710
- this.closeTurn(statusMessage ?? "Pi turn ended when the attempt finished.");
711
- const outcome = forcedOutcome ?? classifyOutcome(message);
712
- if (outcome === "error") this.counters.failedAttempts += 1;
713
- this.lastAttemptOutcome = outcome;
714
- this.attempt.update({
715
- ...(message?.content !== undefined ? { output: this.capture(message.content) } : {}),
716
- metadata: {
717
- "pi.attempt.index": this.attemptIndex ?? Math.max(0, this.counters.attempts - 1),
718
- "pi.attempt.outcome": outcome,
719
- ...(message?.stopReason ? { "pi.attempt.stop_reason": message.stopReason } : {}),
720
- },
721
- ...severityForOutcome(outcome, statusMessage ?? message?.errorMessage),
722
- });
723
- this.attempt.end();
724
- this.attempt = undefined;
725
- this.attemptIndex = undefined;
726
- this.unresolvedToolErrors = 0;
727
- }
728
-
729
- private startToolObservation(toolCallId: string, toolName: string, args?: unknown): Observation {
730
- return this.startObservation(
731
- `pi.tool.${toolName}`,
732
- {
733
- ...(args !== undefined ? { input: this.capture(args) } : {}),
734
- metadata: { "pi.tool.call_id": toolCallId, "pi.tool.name": toolName },
735
- version: TRACE_SCHEMA_VERSION,
736
- },
737
- { asType: "tool", parent: this.turn ?? this.attempt ?? this.root },
738
- );
739
- }
740
-
741
- private closeTurn(statusMessage: string, generationLevel: "ERROR" | "WARNING" = "WARNING"): void {
742
- this.closeGeneration(statusMessage, generationLevel);
743
- this.closeTools(statusMessage);
744
- if (!this.turn) return;
745
- this.turn.update({ level: "WARNING", statusMessage });
746
- this.turn.end();
747
- this.turn = undefined;
748
- this.turnIndex = undefined;
749
- }
750
-
751
- private closeTools(statusMessage: string): void {
752
- for (const tool of this.tools.values()) {
753
- tool.observation.update({
754
- level: "WARNING",
755
- statusMessage,
756
- metadata: {
757
- "pi.tool.progress_update_count": tool.progressUpdates,
758
- ...(tool.firstProgressAt !== undefined
759
- ? {
760
- "pi.tool.time_to_first_progress_ms": Math.max(
761
- 0,
762
- tool.firstProgressAt - tool.startedAt,
763
- ),
764
- }
765
- : {}),
766
- },
767
- });
768
- tool.observation.end();
769
- }
770
- this.tools.clear();
771
- }
772
-
773
- private closeGeneration(statusMessage: string, level: "ERROR" | "WARNING" = "ERROR"): void {
774
- if (!this.generation) return;
775
- this.generation.observation.update({
776
- level,
777
- statusMessage,
778
- metadata: {
779
- ...this.generation.requestMetadata,
780
- ...generationHttpMetadata(this.generation),
781
- },
782
- });
783
- this.generation.observation.end(this.generation.endTime);
784
- this.generation = undefined;
785
- }
786
-
787
- private closeCompaction(statusMessage: string): void {
788
- if (!this.compaction) return;
789
- this.compaction.observation.update({ level: "WARNING", statusMessage });
790
- this.compaction.observation.end();
791
- this.compaction = undefined;
792
- }
793
-
794
- private capture(value: unknown): unknown {
795
- return this.context.captureContent ? sanitizeTraceValue(value) : CONTENT_DISABLED;
796
- }
218
+ private root: Observation | undefined;
219
+ private attempt: Observation | undefined;
220
+ private attemptIndex: number | undefined;
221
+ private turn: Observation | undefined;
222
+ private turnIndex: number | undefined;
223
+ private generation: GenerationState | undefined;
224
+ private readonly tools = new Map<string, ToolState>();
225
+ private readonly duplicateToolIds = new Set<string>();
226
+ private compaction: CompactionState | undefined;
227
+ private lastOutput: unknown;
228
+ private lastAssistant: AssistantMessage | undefined;
229
+ private lastAttemptOutcome: Outcome | undefined;
230
+ private unresolvedToolErrors = 0;
231
+ private startSnapshot: ContextSnapshot | undefined;
232
+ private counters: Counters = emptyCounters();
233
+
234
+ constructor(
235
+ private readonly backend: TraceBackend,
236
+ private readonly context: RecorderContext,
237
+ ) {}
238
+
239
+ private startObservation(
240
+ name: string,
241
+ attributes: ObservationAttributes,
242
+ options: { asType: ObservationType; parent?: Observation },
243
+ ): Observation {
244
+ const { sessionId, userId } = this.context;
245
+ return this.backend.start(name, { ...attributes, sessionId, ...(userId ? { userId } : {}) }, options);
246
+ }
247
+
248
+ hasActiveTrace(): boolean {
249
+ return this.root !== undefined;
250
+ }
251
+
252
+ hasActiveAttempt(): boolean {
253
+ return this.attempt !== undefined;
254
+ }
255
+
256
+ beginAgent(input: BeginAgentInput): void {
257
+ if (this.root) this.closeActiveTrace("Interrupted by a new Pi run.", undefined, "interrupted");
258
+
259
+ this.lastOutput = undefined;
260
+ this.lastAssistant = undefined;
261
+ this.lastAttemptOutcome = undefined;
262
+ this.unresolvedToolErrors = 0;
263
+ this.duplicateToolIds.clear();
264
+ this.startSnapshot = input.snapshot;
265
+ this.counters = emptyCounters();
266
+ const traceInput = this.capture({
267
+ prompt: input.prompt,
268
+ ...(hasItems(input.images) ? { images: input.images } : {}),
269
+ });
270
+ const metadata: Record<string, unknown> = {
271
+ "pi.cwd": this.context.cwd,
272
+ ...(input.git?.branch ? { "pi.git.branch": input.git.branch } : {}),
273
+ ...(input.git?.commit ? { "pi.git.commit": input.git.commit } : {}),
274
+ ...(input.git ? { "pi.git.detached": input.git.detached } : {}),
275
+ "pi.mode": this.context.mode,
276
+ ...(input.model?.id ? { "pi.model": input.model.id } : {}),
277
+ ...(input.model?.provider ? { "pi.provider": input.model.provider } : {}),
278
+ "pi.session.id": this.context.sessionId,
279
+ "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
280
+ ...snapshotMetadata("start", input.snapshot),
281
+ };
282
+ const attributes = { input: traceInput, metadata, version: TRACE_SCHEMA_VERSION };
283
+ const gitTag = input.git?.branch ? `branch:${input.git.branch}` : input.git?.detached ? "git:detached" : undefined;
284
+
285
+ this.root = this.startObservation("pi.agent", attributes, { asType: "agent" });
286
+ this.root.updateTrace?.({
287
+ name: "pi.trace",
288
+ sessionId: this.context.sessionId,
289
+ ...(this.context.userId ? { userId: this.context.userId } : {}),
290
+ version: TRACE_SCHEMA_VERSION,
291
+ input: traceInput,
292
+ metadata,
293
+ tags: ["pi", ...(gitTag ? [gitTag] : [])],
294
+ });
295
+ }
296
+
297
+ beginAttempt(input: AttemptInput = {}): void {
298
+ if (!this.root) return;
299
+ if (this.attempt) {
300
+ this.closeAttempt(undefined, "Interrupted by the next Pi attempt.", "interrupted");
301
+ }
302
+ const index = this.counters.attempts;
303
+ this.counters.attempts += 1;
304
+ this.lastAssistant = undefined;
305
+ this.unresolvedToolErrors = 0;
306
+ this.attemptIndex = index;
307
+ this.attempt = this.startObservation(
308
+ "pi.attempt",
309
+ {
310
+ metadata: {
311
+ "pi.attempt.index": index,
312
+ ...(input.reason ? { "pi.attempt.reason": input.reason } : {}),
313
+ },
314
+ version: TRACE_SCHEMA_VERSION,
315
+ },
316
+ { asType: "span", parent: this.root },
317
+ );
318
+ }
319
+
320
+ finishAttempt(message?: AssistantMessage): void {
321
+ if (message?.role === "assistant") this.finishAssistant(message);
322
+ if (!this.attempt) return;
323
+ const outcome = this.unresolvedToolErrors > 0 ? "error" : classifyOutcome(message);
324
+ this.closeAttempt(
325
+ message,
326
+ this.unresolvedToolErrors > 0 ? "Pi attempt ended after a tool failure." : undefined,
327
+ outcome,
328
+ );
329
+ }
330
+
331
+ beginTurn(turnIndex: number): void {
332
+ if (!this.root) return;
333
+ if (this.turn) this.closeTurn("Interrupted by the next Pi turn.", "ERROR");
334
+ this.counters.turns += 1;
335
+ this.turnIndex = turnIndex;
336
+ this.turn = this.startObservation(
337
+ "pi.turn",
338
+ { metadata: { "pi.turn.index": turnIndex }, version: TRACE_SCHEMA_VERSION },
339
+ { asType: "span", parent: this.attempt ?? this.root },
340
+ );
341
+ }
342
+
343
+ finishTurn(turnIndex: number, result: TurnResult): void {
344
+ if (!this.turn) return;
345
+ this.closeGeneration("Turn ended without a finalized assistant message.");
346
+ this.closeTools("Tool span ended when the Pi turn finished.");
347
+
348
+ const mismatched = this.turnIndex !== turnIndex;
349
+ const outcome = classifyOutcome(result.message);
350
+ this.turn.update({
351
+ metadata: {
352
+ "pi.turn.index": this.turnIndex ?? turnIndex,
353
+ "pi.turn.tool_result_count": result.toolResultCount,
354
+ ...(result.message.stopReason ? { "pi.turn.stop_reason": result.message.stopReason } : {}),
355
+ },
356
+ ...(mismatched && outcome === "success"
357
+ ? {
358
+ level: "WARNING" as const,
359
+ statusMessage: `Turn ${turnIndex} ended while turn ${this.turnIndex} was active.`,
360
+ }
361
+ : severityForOutcome(outcome, result.message.errorMessage)),
362
+ });
363
+ this.turn.end();
364
+ this.turn = undefined;
365
+ this.turnIndex = undefined;
366
+ }
367
+
368
+ beginGeneration(input: GenerationInput = {}): void {
369
+ if (!this.root) return;
370
+ this.closeGeneration("Interrupted by the next provider request.");
371
+ if (this.unresolvedToolErrors > 0) {
372
+ this.counters.recoveredErrors += this.unresolvedToolErrors;
373
+ this.unresolvedToolErrors = 0;
374
+ }
375
+ this.counters.generations += 1;
376
+ const requestMetadata: Record<string, unknown> = {
377
+ ...(input.payloadStage ? { "pi.request.payload_stage": input.payloadStage } : {}),
378
+ ...(input.model?.provider ? { "pi.request.provider": input.model.provider } : {}),
379
+ ...(input.model?.id ? { "pi.request.model": input.model.id } : {}),
380
+ ...(input.model?.api ? { "pi.request.api": input.model.api } : {}),
381
+ ...(input.thinkingLevel ? { "pi.request.thinking_level": input.thinkingLevel } : {}),
382
+ };
383
+ const observation = this.startObservation(
384
+ "pi.llm",
385
+ {
386
+ ...(input.payload !== undefined ? { input: this.capture(input.payload) } : {}),
387
+ ...(input.model?.id ? { model: input.model.id } : {}),
388
+ ...(input.thinkingLevel ? { modelParameters: { thinking_level: input.thinkingLevel } } : {}),
389
+ ...(Object.keys(requestMetadata).length > 0 ? { metadata: requestMetadata } : {}),
390
+ version: TRACE_SCHEMA_VERSION,
391
+ },
392
+ { asType: "generation", parent: this.turn ?? this.attempt ?? this.root },
393
+ );
394
+ this.generation = {
395
+ observation,
396
+ requestMetadata,
397
+ statuses: [],
398
+ headers: {},
399
+ };
400
+ }
401
+
402
+ recordProviderResponse(status: number, headers: Record<string, string> = {}): void {
403
+ if (!this.generation) return;
404
+ this.generation.statuses.push(status);
405
+ Object.assign(this.generation.headers, allowlistedResponseHeaders(headers));
406
+ }
407
+
408
+ markGenerationFirstOutput(timestamp = Date.now()): void {
409
+ if (this.generation && this.generation.completionStartTime === undefined) {
410
+ this.generation.completionStartTime = timestamp;
411
+ }
412
+ }
413
+
414
+ markGenerationEnd(endTime = Date.now()): void {
415
+ if (this.generation && this.generation.endTime === undefined) {
416
+ this.generation.endTime = endTime;
417
+ }
418
+ }
419
+
420
+ finishAssistant(message: AssistantMessage): void {
421
+ if (message.role !== "assistant") return;
422
+ this.lastAssistant = message;
423
+ this.lastOutput = this.capture(message.content);
424
+ if (!this.generation) return;
425
+
426
+ const state = this.generation;
427
+ const usageDetails = numericRecord({
428
+ input: message.usage?.input,
429
+ output: message.usage?.output,
430
+ cache_read_input_tokens: message.usage?.cacheRead,
431
+ cache_creation_input_tokens: message.usage?.cacheWrite,
432
+ total: message.usage?.totalTokens,
433
+ });
434
+ const costDetails = positiveNumericRecord({
435
+ input: message.usage?.cost?.input,
436
+ output: message.usage?.cost?.output,
437
+ cache_read: message.usage?.cost?.cacheRead,
438
+ cache_write: message.usage?.cost?.cacheWrite,
439
+ total: message.usage?.cost?.total,
440
+ });
441
+ const outcome = classifyOutcome(message);
442
+ const responseModel = message.responseModel ?? message.model;
443
+ const httpMetadata = generationHttpMetadata(state);
444
+ const recoveredStatuses = state.statuses.filter((status) => status >= 400).length;
445
+ if (outcome === "success" && recoveredStatuses > 0) {
446
+ this.counters.recoveredErrors += recoveredStatuses;
447
+ }
448
+ state.observation.update({
449
+ output: this.lastOutput,
450
+ ...(state.completionStartTime !== undefined ? { completionStartTime: new Date(state.completionStartTime) } : {}),
451
+ ...(responseModel ? { model: responseModel } : {}),
452
+ ...(Object.keys(usageDetails).length > 0 ? { usageDetails } : {}),
453
+ ...(Object.keys(costDetails).length > 0 ? { costDetails } : {}),
454
+ metadata: {
455
+ ...state.requestMetadata,
456
+ ...httpMetadata,
457
+ ...(message.provider ? { "pi.provider": message.provider, "pi.response.provider": message.provider } : {}),
458
+ ...(message.api ? { "pi.response.api": message.api } : {}),
459
+ ...(responseModel ? { "pi.response.model": responseModel } : {}),
460
+ ...(message.responseId ? { "pi.response.id": message.responseId } : {}),
461
+ ...(message.responseModel && message.model && message.responseModel !== message.model
462
+ ? { "pi.requested_model": message.model }
463
+ : {}),
464
+ ...(message.stopReason ? { "pi.stop_reason": message.stopReason } : {}),
465
+ ...(finiteNumber(message.usage?.reasoning) ? { "pi.usage.reasoning_tokens": message.usage.reasoning } : {}),
466
+ ...(finiteNumber(message.usage?.cacheWrite1h)
467
+ ? { "pi.usage.cache_write_1h_tokens": message.usage.cacheWrite1h }
468
+ : {}),
469
+ },
470
+ ...severityForOutcome(outcome, message.errorMessage),
471
+ });
472
+ state.observation.end(state.endTime);
473
+ this.generation = undefined;
474
+ }
475
+
476
+ beginTool(toolCallId: string, toolName: string, args?: unknown, startedAt = Date.now()): void {
477
+ if (!this.root) return;
478
+ this.counters.tools += 1;
479
+ if (this.duplicateToolIds.has(toolCallId)) {
480
+ this.counters.toolErrors += 1;
481
+ const duplicate = this.startToolObservation(toolCallId, toolName, args);
482
+ duplicate.update({
483
+ level: "ERROR",
484
+ statusMessage: "Duplicate tool call ID cannot be correlated safely.",
485
+ });
486
+ duplicate.end();
487
+ return;
488
+ }
489
+
490
+ const existing = this.tools.get(toolCallId);
491
+ if (existing) {
492
+ existing.observation.update({
493
+ level: "ERROR",
494
+ statusMessage: "Duplicate tool execution started.",
495
+ });
496
+ existing.observation.end();
497
+ this.tools.delete(toolCallId);
498
+ this.counters.toolErrors += 2;
499
+ const duplicate = this.startToolObservation(toolCallId, toolName, args);
500
+ duplicate.update({
501
+ level: "ERROR",
502
+ statusMessage: "Duplicate tool call ID cannot be correlated safely.",
503
+ });
504
+ duplicate.end();
505
+ this.duplicateToolIds.add(toolCallId);
506
+ return;
507
+ }
508
+
509
+ this.tools.set(toolCallId, {
510
+ observation: this.startToolObservation(toolCallId, toolName, args),
511
+ startedAt,
512
+ progressUpdates: 0,
513
+ });
514
+ }
515
+
516
+ recordToolInput(toolCallId: string, input: unknown): void {
517
+ if (input === undefined || this.duplicateToolIds.has(toolCallId)) return;
518
+ this.tools.get(toolCallId)?.observation.update({ input: this.capture(input) });
519
+ }
520
+
521
+ recordToolProgress(toolCallId: string, timestamp = Date.now()): void {
522
+ const tool = this.tools.get(toolCallId);
523
+ if (!tool) return;
524
+ tool.progressUpdates += 1;
525
+ tool.firstProgressAt ??= timestamp;
526
+ }
527
+
528
+ finishTool(toolCallId: string, result: ToolResult): void {
529
+ if (this.duplicateToolIds.has(toolCallId)) return;
530
+ const tool = this.tools.get(toolCallId);
531
+ if (!tool) return;
532
+ if (result.isError) {
533
+ this.counters.toolErrors += 1;
534
+ this.unresolvedToolErrors += 1;
535
+ }
536
+ tool.observation.update({
537
+ output: this.capture({ content: result.content, details: result.details }),
538
+ metadata: {
539
+ "pi.tool.progress_update_count": tool.progressUpdates,
540
+ ...(tool.firstProgressAt !== undefined
541
+ ? {
542
+ "pi.tool.time_to_first_progress_ms": Math.max(0, tool.firstProgressAt - tool.startedAt),
543
+ }
544
+ : {}),
545
+ },
546
+ ...(result.isError ? { level: "ERROR" as const, statusMessage: "Pi tool execution failed." } : {}),
547
+ });
548
+ tool.observation.end();
549
+ this.tools.delete(toolCallId);
550
+ }
551
+
552
+ beginCompaction(input: CompactionStart): void {
553
+ if (!this.root) return;
554
+ this.closeCompaction("Interrupted by another Pi compaction.");
555
+ this.counters.compactions += 1;
556
+ this.compaction = {
557
+ observation: this.startObservation(
558
+ "pi.compaction",
559
+ {
560
+ metadata: {
561
+ "pi.compaction.reason": input.reason,
562
+ "pi.compaction.will_retry": input.willRetry,
563
+ ...(finiteNumber(input.tokensBefore) ? { "pi.compaction.tokens_before": input.tokensBefore } : {}),
564
+ "pi.compaction.messages_to_summarize": input.messagesToSummarize,
565
+ "pi.compaction.turn_prefix_messages": input.turnPrefixMessages,
566
+ "pi.compaction.branch_entries": input.branchEntries,
567
+ "pi.compaction.is_split_turn": input.isSplitTurn,
568
+ },
569
+ version: TRACE_SCHEMA_VERSION,
570
+ },
571
+ { asType: "span", parent: this.root },
572
+ ),
573
+ };
574
+ }
575
+
576
+ finishCompaction(input: CompactionFinish): void {
577
+ if (!this.compaction) return;
578
+ const details = structuralCompactionDetails(input.details);
579
+ const usage = numericRecord({
580
+ input: input.usage?.input,
581
+ output: input.usage?.output,
582
+ cache_read: input.usage?.cacheRead,
583
+ cache_write: input.usage?.cacheWrite,
584
+ total: input.usage?.totalTokens,
585
+ });
586
+ const cost = positiveNumericRecord({
587
+ input: input.usage?.cost?.input,
588
+ output: input.usage?.cost?.output,
589
+ cache_read: input.usage?.cost?.cacheRead,
590
+ cache_write: input.usage?.cost?.cacheWrite,
591
+ total: input.usage?.cost?.total,
592
+ });
593
+ this.compaction.observation.update({
594
+ metadata: {
595
+ "pi.compaction.reason": input.reason,
596
+ "pi.compaction.will_retry": input.willRetry,
597
+ "pi.compaction.from_extension": input.fromExtension,
598
+ ...(finiteNumber(input.tokensBefore) ? { "pi.compaction.tokens_before": input.tokensBefore } : {}),
599
+ ...details,
600
+ ...prefixRecord("pi.compaction.usage", usage),
601
+ ...prefixRecord("pi.compaction.cost", cost),
602
+ },
603
+ });
604
+ this.compaction.observation.end();
605
+ this.compaction = undefined;
606
+ }
607
+
608
+ settle(snapshot?: ContextSnapshot): void {
609
+ this.closeActiveTrace(undefined, snapshot);
610
+ }
611
+
612
+ interrupt(statusMessage: string, snapshot?: ContextSnapshot): void {
613
+ this.closeActiveTrace(statusMessage, snapshot, "interrupted");
614
+ }
615
+
616
+ async flush(): Promise<void> {
617
+ await this.backend.forceFlush();
618
+ }
619
+
620
+ async shutdown(snapshot?: ContextSnapshot): Promise<void> {
621
+ this.closeActiveTrace("Pi shut down before the active trace settled.", snapshot, "interrupted");
622
+ await this.backend.shutdown();
623
+ }
624
+
625
+ private closeActiveTrace(statusMessage?: string, snapshot?: ContextSnapshot, forcedOutcome?: Outcome): void {
626
+ this.closeTurn(statusMessage ?? "Pi turn ended when the agent run settled.");
627
+ this.closeAttempt(undefined, statusMessage, forcedOutcome ?? "interrupted");
628
+ this.closeCompaction(statusMessage ?? "Pi compaction ended when the agent run settled.");
629
+
630
+ if (!this.root) return;
631
+ const baseOutcome = forcedOutcome ?? this.lastAttemptOutcome ?? classifyOutcome(this.lastAssistant);
632
+ const recoveredErrorCount =
633
+ this.counters.recoveredErrors + (baseOutcome === "success" ? this.counters.failedAttempts : 0);
634
+ const traceOutcome = baseOutcome === "success" && recoveredErrorCount > 0 ? "recovered_success" : baseOutcome;
635
+ const finalMetadata: Record<string, unknown> = {
636
+ "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
637
+ "pi.trace.outcome": traceOutcome,
638
+ ...(this.lastAssistant?.stopReason ? { "pi.trace.stop_reason": this.lastAssistant.stopReason } : {}),
639
+ "pi.trace.attempt_count": this.counters.attempts,
640
+ "pi.trace.turn_count": this.counters.turns,
641
+ "pi.trace.generation_count": this.counters.generations,
642
+ "pi.trace.tool_count": this.counters.tools,
643
+ "pi.trace.tool_error_count": this.counters.toolErrors,
644
+ "pi.trace.compaction_count": this.counters.compactions,
645
+ "pi.trace.recovered_error_count": recoveredErrorCount,
646
+ ...snapshotMetadata("start", this.startSnapshot),
647
+ ...snapshotMetadata("end", snapshot),
648
+ };
649
+ const finalAttributes: ObservationAttributes = {
650
+ ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
651
+ metadata: finalMetadata,
652
+ ...severityForOutcome(baseOutcome, statusMessage ?? this.lastAssistant?.errorMessage),
653
+ };
654
+ this.root.update(finalAttributes);
655
+ this.root.updateTrace?.({
656
+ ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
657
+ metadata: finalMetadata,
658
+ version: TRACE_SCHEMA_VERSION,
659
+ });
660
+ this.root.end();
661
+ this.root = undefined;
662
+ this.lastOutput = undefined;
663
+ this.lastAssistant = undefined;
664
+ this.lastAttemptOutcome = undefined;
665
+ this.unresolvedToolErrors = 0;
666
+ this.duplicateToolIds.clear();
667
+ this.startSnapshot = undefined;
668
+ }
669
+
670
+ private closeAttempt(message?: AssistantMessage, statusMessage?: string, forcedOutcome?: Outcome): void {
671
+ if (!this.attempt) return;
672
+ this.closeTurn(statusMessage ?? "Pi turn ended when the attempt finished.");
673
+ const outcome = forcedOutcome ?? classifyOutcome(message);
674
+ if (outcome === "error") this.counters.failedAttempts += 1;
675
+ this.lastAttemptOutcome = outcome;
676
+ this.attempt.update({
677
+ ...(message?.content !== undefined ? { output: this.capture(message.content) } : {}),
678
+ metadata: {
679
+ "pi.attempt.index": this.attemptIndex ?? Math.max(0, this.counters.attempts - 1),
680
+ "pi.attempt.outcome": outcome,
681
+ ...(message?.stopReason ? { "pi.attempt.stop_reason": message.stopReason } : {}),
682
+ },
683
+ ...severityForOutcome(outcome, statusMessage ?? message?.errorMessage),
684
+ });
685
+ this.attempt.end();
686
+ this.attempt = undefined;
687
+ this.attemptIndex = undefined;
688
+ this.unresolvedToolErrors = 0;
689
+ }
690
+
691
+ private startToolObservation(toolCallId: string, toolName: string, args?: unknown): Observation {
692
+ return this.startObservation(
693
+ `pi.tool.${toolName}`,
694
+ {
695
+ ...(args !== undefined ? { input: this.capture(args) } : {}),
696
+ metadata: { "pi.tool.call_id": toolCallId, "pi.tool.name": toolName },
697
+ version: TRACE_SCHEMA_VERSION,
698
+ },
699
+ { asType: "tool", parent: this.turn ?? this.attempt ?? this.root },
700
+ );
701
+ }
702
+
703
+ private closeTurn(statusMessage: string, generationLevel: "ERROR" | "WARNING" = "WARNING"): void {
704
+ this.closeGeneration(statusMessage, generationLevel);
705
+ this.closeTools(statusMessage);
706
+ if (!this.turn) return;
707
+ this.turn.update({ level: "WARNING", statusMessage });
708
+ this.turn.end();
709
+ this.turn = undefined;
710
+ this.turnIndex = undefined;
711
+ }
712
+
713
+ private closeTools(statusMessage: string): void {
714
+ for (const tool of this.tools.values()) {
715
+ tool.observation.update({
716
+ level: "WARNING",
717
+ statusMessage,
718
+ metadata: {
719
+ "pi.tool.progress_update_count": tool.progressUpdates,
720
+ ...(tool.firstProgressAt !== undefined
721
+ ? {
722
+ "pi.tool.time_to_first_progress_ms": Math.max(0, tool.firstProgressAt - tool.startedAt),
723
+ }
724
+ : {}),
725
+ },
726
+ });
727
+ tool.observation.end();
728
+ }
729
+ this.tools.clear();
730
+ }
731
+
732
+ private closeGeneration(statusMessage: string, level: "ERROR" | "WARNING" = "ERROR"): void {
733
+ if (!this.generation) return;
734
+ this.generation.observation.update({
735
+ level,
736
+ statusMessage,
737
+ metadata: {
738
+ ...this.generation.requestMetadata,
739
+ ...generationHttpMetadata(this.generation),
740
+ },
741
+ });
742
+ this.generation.observation.end(this.generation.endTime);
743
+ this.generation = undefined;
744
+ }
745
+
746
+ private closeCompaction(statusMessage: string): void {
747
+ if (!this.compaction) return;
748
+ this.compaction.observation.update({ level: "WARNING", statusMessage });
749
+ this.compaction.observation.end();
750
+ this.compaction = undefined;
751
+ }
752
+
753
+ private capture(value: unknown): unknown {
754
+ return this.context.captureContent ? sanitizeTraceValue(value) : CONTENT_DISABLED;
755
+ }
797
756
  }
798
757
 
799
758
  function generationHttpMetadata(state: GenerationState): Record<string, unknown> {
800
- const finalStatus = state.statuses.at(-1);
801
- return {
802
- ...(state.statuses.length > 0
803
- ? {
804
- "http.response.status_codes": [...state.statuses],
805
- "http.response.status_code": finalStatus,
806
- "http.response.attempt_count": state.statuses.length,
807
- "http.response.retry_count": Math.max(0, state.statuses.length - 1),
808
- }
809
- : {}),
810
- ...(Object.keys(state.headers).length > 0
811
- ? { "http.response.headers": { ...state.headers } }
812
- : {}),
813
- };
759
+ const finalStatus = state.statuses.at(-1);
760
+ return {
761
+ ...(state.statuses.length > 0
762
+ ? {
763
+ "http.response.status_codes": [...state.statuses],
764
+ "http.response.status_code": finalStatus,
765
+ "http.response.attempt_count": state.statuses.length,
766
+ "http.response.retry_count": Math.max(0, state.statuses.length - 1),
767
+ }
768
+ : {}),
769
+ ...(Object.keys(state.headers).length > 0 ? { "http.response.headers": { ...state.headers } } : {}),
770
+ };
814
771
  }
815
772
 
816
773
  function allowlistedResponseHeaders(headers: Record<string, string>): Record<string, string> {
817
- const output: Record<string, string> = {};
818
- for (const [rawName, rawValue] of Object.entries(headers)) {
819
- const name = rawName.toLowerCase();
820
- if (!ALLOWED_RESPONSE_HEADERS.has(name) || typeof rawValue !== "string") continue;
821
- output[name] = truncateString(rawValue, MAX_HEADER_VALUE_LENGTH);
822
- }
823
- return output;
774
+ const output: Record<string, string> = {};
775
+ for (const [rawName, rawValue] of Object.entries(headers)) {
776
+ const name = rawName.toLowerCase();
777
+ if (!ALLOWED_RESPONSE_HEADERS.has(name) || typeof rawValue !== "string") continue;
778
+ output[name] = truncateString(rawValue, MAX_HEADER_VALUE_LENGTH);
779
+ }
780
+ return output;
824
781
  }
825
782
 
826
783
  function structuralCompactionDetails(details: unknown): Record<string, unknown> {
827
- if (!details || typeof details !== "object" || Array.isArray(details)) return {};
828
- const record = details as Record<string, unknown>;
829
- return {
830
- ...(Array.isArray(record.readFiles)
831
- ? { "pi.compaction.read_file_count": record.readFiles.length }
832
- : {}),
833
- ...(Array.isArray(record.modifiedFiles)
834
- ? { "pi.compaction.modified_file_count": record.modifiedFiles.length }
835
- : {}),
836
- };
784
+ if (!details || typeof details !== "object" || Array.isArray(details)) return {};
785
+ const record = details as Record<string, unknown>;
786
+ return {
787
+ ...(Array.isArray(record.readFiles) ? { "pi.compaction.read_file_count": record.readFiles.length } : {}),
788
+ ...(Array.isArray(record.modifiedFiles)
789
+ ? { "pi.compaction.modified_file_count": record.modifiedFiles.length }
790
+ : {}),
791
+ };
837
792
  }
838
793
 
839
794
  function snapshotMetadata(prefix: string, snapshot?: ContextSnapshot): Record<string, unknown> {
840
- if (!snapshot) return {};
841
- return {
842
- ...(snapshot.leafId ? { [`pi.trace.${prefix}_leaf_id`]: snapshot.leafId } : {}),
843
- ...(snapshot.contextUsage
844
- ? {
845
- ...(finiteNumber(snapshot.contextUsage.tokens)
846
- ? { [`pi.trace.${prefix}_context_tokens`]: snapshot.contextUsage.tokens }
847
- : {}),
848
- [`pi.trace.${prefix}_context_window`]: snapshot.contextUsage.contextWindow,
849
- ...(finiteNumber(snapshot.contextUsage.percent)
850
- ? { [`pi.trace.${prefix}_context_percent`]: snapshot.contextUsage.percent }
851
- : {}),
852
- }
853
- : {}),
854
- };
855
- }
856
-
857
- function classifyOutcome(message?: {
858
- role?: string;
859
- stopReason?: string;
860
- errorMessage?: string;
861
- }): Outcome {
862
- if (message?.role !== "assistant") return "interrupted";
863
- if (message.stopReason === "error" || message.errorMessage) return "error";
864
- if (message.stopReason === "aborted") return "aborted";
865
- if (message.stopReason === "length") return "length";
866
- return "success";
795
+ if (!snapshot) return {};
796
+ return {
797
+ ...(snapshot.leafId ? { [`pi.trace.${prefix}_leaf_id`]: snapshot.leafId } : {}),
798
+ ...(snapshot.contextUsage
799
+ ? {
800
+ ...(finiteNumber(snapshot.contextUsage.tokens)
801
+ ? { [`pi.trace.${prefix}_context_tokens`]: snapshot.contextUsage.tokens }
802
+ : {}),
803
+ [`pi.trace.${prefix}_context_window`]: snapshot.contextUsage.contextWindow,
804
+ ...(finiteNumber(snapshot.contextUsage.percent)
805
+ ? { [`pi.trace.${prefix}_context_percent`]: snapshot.contextUsage.percent }
806
+ : {}),
807
+ }
808
+ : {}),
809
+ };
810
+ }
811
+
812
+ function classifyOutcome(message?: { role?: string; stopReason?: string; errorMessage?: string }): Outcome {
813
+ if (message?.role !== "assistant") return "interrupted";
814
+ if (message.stopReason === "error" || message.errorMessage) return "error";
815
+ if (message.stopReason === "aborted") return "aborted";
816
+ if (message.stopReason === "length") return "length";
817
+ return "success";
867
818
  }
868
819
 
869
820
  function severityForOutcome(
870
- outcome: Outcome,
871
- statusMessage?: string,
821
+ outcome: Outcome,
822
+ statusMessage?: string,
872
823
  ): Pick<ObservationAttributes, "level" | "statusMessage"> {
873
- if (outcome === "success") return {};
874
- if (outcome === "error") {
875
- return {
876
- level: "ERROR",
877
- statusMessage: statusMessage ?? "The Pi operation failed.",
878
- };
879
- }
880
- return {
881
- level: "WARNING",
882
- statusMessage:
883
- statusMessage ??
884
- (outcome === "aborted"
885
- ? "The Pi operation was aborted."
886
- : outcome === "length"
887
- ? "The model reached its output limit."
888
- : "The Pi operation was interrupted."),
889
- };
824
+ if (outcome === "success") return {};
825
+ if (outcome === "error") {
826
+ return {
827
+ level: "ERROR",
828
+ statusMessage: statusMessage ?? "The Pi operation failed.",
829
+ };
830
+ }
831
+ return {
832
+ level: "WARNING",
833
+ statusMessage:
834
+ statusMessage ??
835
+ (outcome === "aborted"
836
+ ? "The Pi operation was aborted."
837
+ : outcome === "length"
838
+ ? "The model reached its output limit."
839
+ : "The Pi operation was interrupted."),
840
+ };
890
841
  }
891
842
 
892
843
  function prefixRecord(prefix: string, values: Record<string, number>): Record<string, number> {
893
- return Object.fromEntries(
894
- Object.entries(values).map(([key, value]) => [`${prefix}.${key}`, value]),
895
- );
844
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [`${prefix}.${key}`, value]));
896
845
  }
897
846
 
898
847
  function truncateString(value: string, maxBytes: number): string {
899
- const boundedPrefix = value.slice(0, maxBytes);
900
- if (boundedPrefix.length === value.length && byteLength(boundedPrefix) <= maxBytes) {
901
- return boundedPrefix;
902
- }
903
- const suffix = "… [truncated]";
904
- const target = Math.max(0, maxBytes - byteLength(suffix) - 2);
905
- let bytes = 0;
906
- let output = "";
907
- for (const character of boundedPrefix) {
908
- const size = byteLength(character);
909
- if (bytes + size > target) break;
910
- output += character;
911
- bytes += size;
912
- }
913
- return `${output}${suffix}`;
848
+ const boundedPrefix = value.slice(0, maxBytes);
849
+ if (boundedPrefix.length === value.length && byteLength(boundedPrefix) <= maxBytes) {
850
+ return boundedPrefix;
851
+ }
852
+ const suffix = "… [truncated]";
853
+ const target = Math.max(0, maxBytes - byteLength(suffix) - 2);
854
+ let bytes = 0;
855
+ let output = "";
856
+ for (const character of boundedPrefix) {
857
+ const size = byteLength(character);
858
+ if (bytes + size > target) break;
859
+ output += character;
860
+ bytes += size;
861
+ }
862
+ return `${output}${suffix}`;
914
863
  }
915
864
 
916
865
  function byteLength(value: string): number {
917
- return Buffer.byteLength(value, "utf8");
866
+ return Buffer.byteLength(value, "utf8");
918
867
  }
919
868
 
920
869
  function numericRecord(values: Record<string, number | undefined>): Record<string, number> {
921
- return Object.fromEntries(
922
- Object.entries(values).filter((entry): entry is [string, number] => finiteNumber(entry[1])),
923
- );
870
+ return Object.fromEntries(
871
+ Object.entries(values).filter((entry): entry is [string, number] => finiteNumber(entry[1])),
872
+ );
924
873
  }
925
874
 
926
875
  function positiveNumericRecord(values: Record<string, number | undefined>): Record<string, number> {
927
- return Object.fromEntries(
928
- Object.entries(values).filter(
929
- (entry): entry is [string, number] => finiteNumber(entry[1]) && entry[1] > 0,
930
- ),
931
- );
876
+ return Object.fromEntries(
877
+ Object.entries(values).filter((entry): entry is [string, number] => finiteNumber(entry[1]) && entry[1] > 0),
878
+ );
932
879
  }
933
880
 
934
881
  function finiteNumber(value: unknown): value is number {
935
- return typeof value === "number" && Number.isFinite(value);
882
+ return typeof value === "number" && Number.isFinite(value);
936
883
  }
937
884
 
938
885
  function hasItems(value: unknown): boolean {
939
- return Array.isArray(value) ? value.length > 0 : value !== undefined;
886
+ return Array.isArray(value) ? value.length > 0 : value !== undefined;
940
887
  }
941
888
 
942
889
  function emptyCounters(): Counters {
943
- return {
944
- attempts: 0,
945
- turns: 0,
946
- generations: 0,
947
- tools: 0,
948
- toolErrors: 0,
949
- compactions: 0,
950
- recoveredErrors: 0,
951
- failedAttempts: 0,
952
- };
890
+ return {
891
+ attempts: 0,
892
+ turns: 0,
893
+ generations: 0,
894
+ tools: 0,
895
+ toolErrors: 0,
896
+ compactions: 0,
897
+ recoveredErrors: 0,
898
+ failedAttempts: 0,
899
+ };
953
900
  }