@narumitw/pi-langfuse 0.14.1

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 ADDED
@@ -0,0 +1,472 @@
1
+ export const MAX_CAPTURE_BYTES = 64 * 1024;
2
+ const MAX_STRING_LENGTH = 50_000;
3
+ const MAX_COLLECTION_LENGTH = 200;
4
+ const MAX_DEPTH = 12;
5
+ const CONTENT_DISABLED = "[content capture disabled]";
6
+ const TRUNCATED = "[truncated: content budget exceeded]";
7
+ const BASE64_DATA_URI = /data:[^,\s"'`]*;base64,[^\s"'`]*/gi;
8
+ const BASE64_DATA_URI_OMITTED = "[base64 data URI omitted]";
9
+
10
+ export interface ObservationAttributes {
11
+ input?: unknown;
12
+ output?: unknown;
13
+ metadata?: Record<string, unknown>;
14
+ level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
15
+ statusMessage?: string;
16
+ model?: string;
17
+ usageDetails?: Record<string, number>;
18
+ costDetails?: Record<string, number>;
19
+ name?: string;
20
+ sessionId?: string;
21
+ tags?: string[];
22
+ }
23
+
24
+ export interface Observation {
25
+ update(attributes: ObservationAttributes): Observation;
26
+ updateTrace?(attributes: ObservationAttributes): Observation;
27
+ end(endTime?: number): Observation;
28
+ }
29
+
30
+ export type ObservationType = "agent" | "generation" | "span" | "tool";
31
+
32
+ export interface TraceBackend {
33
+ start(
34
+ name: string,
35
+ attributes: ObservationAttributes,
36
+ options: { asType: ObservationType; parent?: Observation },
37
+ ): Observation;
38
+ forceFlush(): Promise<void>;
39
+ shutdown(): Promise<void>;
40
+ }
41
+
42
+ interface RecorderContext {
43
+ sessionId: string;
44
+ cwd: string;
45
+ mode: string;
46
+ captureContent: boolean;
47
+ }
48
+
49
+ interface ModelDescriptor {
50
+ provider?: string;
51
+ id?: string;
52
+ }
53
+
54
+ interface BeginAgentInput {
55
+ prompt: unknown;
56
+ images?: unknown;
57
+ model?: ModelDescriptor;
58
+ }
59
+
60
+ interface AssistantMessage {
61
+ role: string;
62
+ content?: unknown;
63
+ provider?: string;
64
+ model?: string;
65
+ responseModel?: string;
66
+ usage?: {
67
+ input?: number;
68
+ output?: number;
69
+ cacheRead?: number;
70
+ cacheWrite?: number;
71
+ totalTokens?: number;
72
+ cost?: { total?: number };
73
+ };
74
+ stopReason?: string;
75
+ errorMessage?: string;
76
+ }
77
+
78
+ interface ToolResult {
79
+ content?: unknown;
80
+ details?: unknown;
81
+ isError?: boolean;
82
+ }
83
+
84
+ interface TurnResult {
85
+ message: { role: string; stopReason?: string; errorMessage?: string };
86
+ toolResultCount: number;
87
+ }
88
+
89
+ export class TraceRecorder {
90
+ private root: Observation | undefined;
91
+ private turn: Observation | undefined;
92
+ private turnIndex: number | undefined;
93
+ private generation: Observation | undefined;
94
+ private generationEndTime: number | undefined;
95
+ private readonly tools = new Map<string, Observation>();
96
+ private lastOutput: unknown;
97
+
98
+ constructor(
99
+ private readonly backend: TraceBackend,
100
+ private readonly context: RecorderContext,
101
+ ) {}
102
+
103
+ hasActiveTrace(): boolean {
104
+ return this.root !== undefined;
105
+ }
106
+
107
+ beginAgent(input: BeginAgentInput): void {
108
+ if (this.root) this.closeActiveTrace("Interrupted by a new Pi agent run.");
109
+
110
+ this.lastOutput = undefined;
111
+ const traceInput = this.capture({
112
+ prompt: input.prompt,
113
+ ...(hasItems(input.images) ? { images: input.images } : {}),
114
+ });
115
+ const metadata: Record<string, unknown> = {
116
+ "pi.cwd": this.context.cwd,
117
+ "pi.mode": this.context.mode,
118
+ ...(input.model?.id ? { "pi.model": input.model.id } : {}),
119
+ ...(input.model?.provider ? { "pi.provider": input.model.provider } : {}),
120
+ "pi.session.id": this.context.sessionId,
121
+ };
122
+
123
+ this.root = this.backend.start("pi.agent", { input: traceInput, metadata }, { asType: "agent" });
124
+ this.root.updateTrace?.({
125
+ name: "pi.trace",
126
+ sessionId: this.context.sessionId,
127
+ input: traceInput,
128
+ metadata,
129
+ tags: ["pi"],
130
+ });
131
+ }
132
+
133
+ beginTurn(turnIndex: number): void {
134
+ if (!this.root) return;
135
+ if (this.turn) this.closeTurn("Interrupted by the next Pi turn.");
136
+ this.turnIndex = turnIndex;
137
+ this.turn = this.backend.start(
138
+ "pi.turn",
139
+ { metadata: { "pi.turn.index": turnIndex } },
140
+ { asType: "span", parent: this.root },
141
+ );
142
+ }
143
+
144
+ finishTurn(turnIndex: number, result: TurnResult): void {
145
+ if (!this.turn) return;
146
+ this.closeGeneration("Turn ended without a finalized assistant message.");
147
+ this.closeTools("Tool span ended when the Pi turn finished.");
148
+
149
+ const mismatched = this.turnIndex !== turnIndex;
150
+ const failed =
151
+ result.message.role === "assistant" &&
152
+ (result.message.stopReason === "error" || Boolean(result.message.errorMessage));
153
+ this.turn.update({
154
+ metadata: {
155
+ "pi.turn.index": this.turnIndex ?? turnIndex,
156
+ "pi.turn.tool_result_count": result.toolResultCount,
157
+ ...(result.message.stopReason
158
+ ? { "pi.turn.stop_reason": result.message.stopReason }
159
+ : {}),
160
+ },
161
+ ...(failed
162
+ ? {
163
+ level: "ERROR" as const,
164
+ statusMessage: result.message.errorMessage ?? "The Pi turn failed.",
165
+ }
166
+ : mismatched
167
+ ? {
168
+ level: "WARNING" as const,
169
+ statusMessage: `Turn ${turnIndex} ended while turn ${this.turnIndex} was active.`,
170
+ }
171
+ : {}),
172
+ });
173
+ this.turn.end();
174
+ this.turn = undefined;
175
+ this.turnIndex = undefined;
176
+ }
177
+
178
+ beginGeneration(): void {
179
+ if (!this.root) return;
180
+ this.closeGeneration("Interrupted by the next provider request.");
181
+ this.generationEndTime = undefined;
182
+ this.generation = this.backend.start(
183
+ "pi.llm",
184
+ {},
185
+ { asType: "generation", parent: this.turn ?? this.root },
186
+ );
187
+ }
188
+
189
+ recordProviderResponse(status: number): void {
190
+ if (!this.generation) return;
191
+ this.generation.update({
192
+ metadata: { "http.response.status_code": status },
193
+ ...(status >= 400
194
+ ? { level: "ERROR" as const, statusMessage: `Provider returned HTTP ${status}.` }
195
+ : {}),
196
+ });
197
+ }
198
+
199
+ markGenerationEnd(endTime = Date.now()): void {
200
+ if (this.generation && this.generationEndTime === undefined) {
201
+ this.generationEndTime = endTime;
202
+ }
203
+ }
204
+
205
+ finishAssistant(message: AssistantMessage): void {
206
+ if (message.role !== "assistant") return;
207
+ this.lastOutput = this.capture(message.content);
208
+ if (!this.generation) return;
209
+
210
+ const usageDetails = numericRecord({
211
+ input: message.usage?.input,
212
+ output: message.usage?.output,
213
+ cache_read_input_tokens: message.usage?.cacheRead,
214
+ cache_creation_input_tokens: message.usage?.cacheWrite,
215
+ total: message.usage?.totalTokens,
216
+ });
217
+ const totalCost = message.usage?.cost?.total;
218
+ const failed = message.stopReason === "error" || Boolean(message.errorMessage);
219
+ const responseModel = message.responseModel ?? message.model;
220
+ this.generation.update({
221
+ output: this.lastOutput,
222
+ ...(responseModel ? { model: responseModel } : {}),
223
+ ...(Object.keys(usageDetails).length > 0 ? { usageDetails } : {}),
224
+ ...(typeof totalCost === "number" && Number.isFinite(totalCost) && totalCost > 0
225
+ ? { costDetails: { total: totalCost } }
226
+ : {}),
227
+ metadata: {
228
+ ...(message.provider ? { "pi.provider": message.provider } : {}),
229
+ ...(message.responseModel && message.model && message.responseModel !== message.model
230
+ ? { "pi.requested_model": message.model }
231
+ : {}),
232
+ ...(message.stopReason ? { "pi.stop_reason": message.stopReason } : {}),
233
+ },
234
+ ...(failed
235
+ ? {
236
+ level: "ERROR" as const,
237
+ statusMessage: message.errorMessage ?? "The model returned an error.",
238
+ }
239
+ : {}),
240
+ });
241
+ this.generation.end(this.generationEndTime);
242
+ this.generation = undefined;
243
+ this.generationEndTime = undefined;
244
+ }
245
+
246
+ beginTool(toolCallId: string, toolName: string, args?: unknown): void {
247
+ if (!this.root) return;
248
+ const existing = this.tools.get(toolCallId);
249
+ if (existing) {
250
+ existing.update({ level: "ERROR", statusMessage: "Duplicate tool execution started." });
251
+ existing.end();
252
+ }
253
+ this.tools.set(
254
+ toolCallId,
255
+ this.backend.start(
256
+ `pi.tool.${toolName}`,
257
+ {
258
+ ...(args !== undefined ? { input: this.capture(args) } : {}),
259
+ metadata: { "pi.tool.call_id": toolCallId, "pi.tool.name": toolName },
260
+ },
261
+ { asType: "tool", parent: this.turn ?? this.root },
262
+ ),
263
+ );
264
+ }
265
+
266
+ updateToolInput(toolCallId: string, input: unknown): void {
267
+ this.tools.get(toolCallId)?.update({ input: this.capture(input) });
268
+ }
269
+
270
+ finishTool(toolCallId: string, result: ToolResult): void {
271
+ const tool = this.tools.get(toolCallId);
272
+ if (!tool) return;
273
+ tool.update({
274
+ output: this.capture({ content: result.content, details: result.details }),
275
+ ...(result.isError
276
+ ? { level: "ERROR" as const, statusMessage: "Pi tool execution failed." }
277
+ : {}),
278
+ });
279
+ tool.end();
280
+ this.tools.delete(toolCallId);
281
+ }
282
+
283
+ settle(): void {
284
+ this.closeActiveTrace();
285
+ }
286
+
287
+ async flush(): Promise<void> {
288
+ await this.backend.forceFlush();
289
+ }
290
+
291
+ async shutdown(): Promise<void> {
292
+ this.closeActiveTrace("Pi shut down before the active trace settled.");
293
+ await this.backend.shutdown();
294
+ }
295
+
296
+ private closeActiveTrace(statusMessage?: string): void {
297
+ this.closeTurn(statusMessage ?? "Pi turn ended when the agent settled.");
298
+
299
+ if (!this.root) return;
300
+ this.root.update({
301
+ ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
302
+ ...(statusMessage ? { level: "WARNING" as const, statusMessage } : {}),
303
+ });
304
+ this.root.updateTrace?.({
305
+ ...(this.lastOutput !== undefined ? { output: this.lastOutput } : {}),
306
+ ...(statusMessage ? { metadata: { "pi.status": statusMessage } } : {}),
307
+ });
308
+ this.root.end();
309
+ this.root = undefined;
310
+ this.lastOutput = undefined;
311
+ }
312
+
313
+ private closeTurn(statusMessage: string): void {
314
+ this.closeGeneration(statusMessage);
315
+ this.closeTools(statusMessage);
316
+ if (!this.turn) return;
317
+ this.turn.update({ level: "WARNING", statusMessage });
318
+ this.turn.end();
319
+ this.turn = undefined;
320
+ this.turnIndex = undefined;
321
+ }
322
+
323
+ private closeTools(statusMessage: string): void {
324
+ for (const tool of this.tools.values()) {
325
+ tool.update({ level: "WARNING", statusMessage });
326
+ tool.end();
327
+ }
328
+ this.tools.clear();
329
+ }
330
+
331
+ private closeGeneration(statusMessage: string): void {
332
+ if (!this.generation) return;
333
+ this.generation.update({ level: "ERROR", statusMessage });
334
+ this.generation.end(this.generationEndTime);
335
+ this.generation = undefined;
336
+ this.generationEndTime = undefined;
337
+ }
338
+
339
+ private capture(value: unknown): unknown {
340
+ return this.context.captureContent ? sanitizeTraceValue(value) : CONTENT_DISABLED;
341
+ }
342
+ }
343
+
344
+ export function sanitizeTraceValue(value: unknown): unknown {
345
+ const budget = { remaining: MAX_CAPTURE_BYTES };
346
+ const sanitized = sanitize(value, new WeakSet<object>(), 0, budget);
347
+ return serializedBytes(sanitized) <= MAX_CAPTURE_BYTES ? sanitized : TRUNCATED;
348
+ }
349
+
350
+ function sanitize(
351
+ value: unknown,
352
+ active: WeakSet<object>,
353
+ depth: number,
354
+ budget: { remaining: number },
355
+ ): unknown {
356
+ if (budget.remaining <= byteLength(TRUNCATED)) return TRUNCATED;
357
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
358
+ return consume(value, budget);
359
+ }
360
+ if (typeof value === "string") {
361
+ const redacted = value.replace(BASE64_DATA_URI, BASE64_DATA_URI_OMITTED);
362
+ const bounded = truncateString(redacted, Math.min(MAX_STRING_LENGTH, budget.remaining));
363
+ return consume(bounded, budget);
364
+ }
365
+ if (typeof value === "bigint") return consume(value.toString(), budget);
366
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
367
+ return undefined;
368
+ }
369
+ if (value instanceof Date) {
370
+ return consume(Number.isNaN(value.getTime()) ? "[invalid date]" : value.toISOString(), budget);
371
+ }
372
+ if (value instanceof Error) {
373
+ return sanitize({ name: value.name, message: value.message }, active, depth, budget);
374
+ }
375
+ if (depth >= MAX_DEPTH) return consume("[maximum depth reached]", budget);
376
+ if (active.has(value)) return consume("[circular]", budget);
377
+ active.add(value);
378
+
379
+ let result: unknown;
380
+ if (Array.isArray(value)) {
381
+ const items: unknown[] = [];
382
+ for (const item of value.slice(0, MAX_COLLECTION_LENGTH)) {
383
+ if (budget.remaining <= byteLength(TRUNCATED)) break;
384
+ items.push(sanitize(item, active, depth + 1, budget));
385
+ }
386
+ if (items.length < value.length) items.push(`[${value.length - items.length} items omitted]`);
387
+ result = items;
388
+ } else {
389
+ const record = value as Record<string, unknown>;
390
+ let keys: string[];
391
+ try {
392
+ keys = Object.keys(record);
393
+ } catch {
394
+ active.delete(value);
395
+ return consume("[unreadable object]", budget);
396
+ }
397
+ const output: Record<string, unknown> = {};
398
+ const objectType = readProperty(record, "type").value;
399
+ const redactData = objectType === "image" || objectType === "base64";
400
+ let processed = 0;
401
+ for (const key of keys.slice(0, MAX_COLLECTION_LENGTH)) {
402
+ if (budget.remaining <= byteLength(TRUNCATED)) break;
403
+ budget.remaining -= byteLength(key) + 4;
404
+ const property = readProperty(record, key);
405
+ if (redactData && key === "data") {
406
+ output[key] = consume("[base64 omitted]", budget);
407
+ } else if (!property.ok) output[key] = consume("[unreadable property]", budget);
408
+ else output[key] = sanitize(property.value, active, depth + 1, budget);
409
+ processed += 1;
410
+ }
411
+ if (processed < keys.length) {
412
+ output["$truncated"] = `${keys.length - processed} object entries omitted`;
413
+ }
414
+ result = output;
415
+ }
416
+ active.delete(value);
417
+ return result;
418
+ }
419
+
420
+ function readProperty(
421
+ record: Record<string, unknown>,
422
+ key: string,
423
+ ): { ok: true; value: unknown } | { ok: false; value?: undefined } {
424
+ try {
425
+ return { ok: true, value: record[key] };
426
+ } catch {
427
+ return { ok: false };
428
+ }
429
+ }
430
+
431
+ function consume<T>(value: T, budget: { remaining: number }): T | string {
432
+ const size = serializedBytes(value);
433
+ if (size > budget.remaining) {
434
+ budget.remaining -= byteLength(TRUNCATED);
435
+ return TRUNCATED;
436
+ }
437
+ budget.remaining -= size;
438
+ return value;
439
+ }
440
+
441
+ function truncateString(value: string, maxBytes: number): string {
442
+ if (byteLength(value) <= maxBytes) return value;
443
+ const suffix = "… [truncated]";
444
+ const target = Math.max(0, maxBytes - byteLength(suffix) - 2);
445
+ let bytes = 0;
446
+ let output = "";
447
+ for (const character of value) {
448
+ const size = byteLength(character);
449
+ if (bytes + size > target) break;
450
+ output += character;
451
+ bytes += size;
452
+ }
453
+ return `${output}${suffix}`;
454
+ }
455
+
456
+ function serializedBytes(value: unknown): number {
457
+ return byteLength(JSON.stringify(value) ?? "null");
458
+ }
459
+
460
+ function byteLength(value: string): number {
461
+ return Buffer.byteLength(value, "utf8");
462
+ }
463
+
464
+ function numericRecord(values: Record<string, number | undefined>): Record<string, number> {
465
+ return Object.fromEntries(
466
+ Object.entries(values).filter((entry): entry is [string, number] => Number.isFinite(entry[1])),
467
+ );
468
+ }
469
+
470
+ function hasItems(value: unknown): boolean {
471
+ return Array.isArray(value) ? value.length > 0 : value !== undefined;
472
+ }