@narumitw/pi-langfuse 0.50.5 → 0.51.0

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