pi-langfuse 1.0.0 → 1.2.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/index.ts CHANGED
@@ -1,281 +1,41 @@
1
1
  /**
2
2
  * Langfuse Observability Extension for Pi Coding Agent
3
- *
4
- * Sends traces to Langfuse for monitoring tokens, costs, latency, and tool calls.
5
- * Uses dynamic import to load the langfuse SDK properly.
6
- *
7
- * Scores tracked:
8
- * - tool_call_count: Total number of tool calls
9
- * - turn_count: Number of turns in the session
10
- * - total_tool_errors: Number of tools that returned errors
11
- * - tool_success_rate: Success rate of tool calls (0-1)
12
- * - session_had_errors: Boolean indicating if any tool error occurred
13
- * - tool_is_error: Per-tool score indicating if that specific tool call errored
3
+ *
4
+ * Sends one complete Langfuse trace per Pi agent run:
5
+ * - root agent observation for the user prompt and final assistant response
6
+ * - one generation observation per provider request
7
+ * - one tool observation per tool call, keyed by toolCallId
14
8
  */
15
9
 
16
- import { readFileSync, existsSync, writeFileSync } from "node:fs";
17
- import { resolve, dirname, basename } from "node:path";
18
- import { fileURLToPath } from "node:url";
10
+ import { basename } from "node:path";
19
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
12
 
21
- // ============================================
22
- // Configuration
23
- // ============================================
24
-
25
- interface Config {
26
- publicKey: string;
27
- secretKey: string;
28
- host: string;
29
- }
30
-
31
- const EXT_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
32
- const CONFIG_PATH = resolve(EXT_DIR, "config.json");
33
- const DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com";
34
-
35
- function loadConfigFromFile(): Config | null {
36
- if (existsSync(CONFIG_PATH)) {
37
- try {
38
- const content = readFileSync(CONFIG_PATH, "utf-8");
39
- const config = JSON.parse(content) as Config;
40
- if (config.publicKey && config.secretKey) {
41
- return {
42
- publicKey: config.publicKey,
43
- secretKey: config.secretKey,
44
- host: config.host || DEFAULT_LANGFUSE_HOST,
45
- };
46
- }
47
- } catch (e) {
48
- console.warn("📊 Langfuse: Failed to load config.json", e);
49
- }
50
- }
51
-
52
- return null;
53
- }
54
-
55
- function loadConfigFromEnv(): Config | null {
56
- const publicKey = process.env.LANGFUSE_PUBLIC_KEY || "";
57
- const secretKey = process.env.LANGFUSE_SECRET_KEY || "";
58
- if (!publicKey || !secretKey) {
59
- return null;
60
- }
61
-
62
- return {
63
- publicKey,
64
- secretKey,
65
- host: process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
66
- };
67
- }
68
-
69
- function saveConfig(config: Config) {
70
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
71
- }
72
-
73
- // ============================================
74
- // Langfuse Client (lazy-loaded via dynamic import)
75
- // ============================================
76
-
77
- interface LangfuseSpan {
78
- id: string;
79
- end(body?: { metadata?: Record<string, unknown>; isError?: boolean; output?: unknown }): void;
80
- }
81
-
82
- interface LangfuseGeneration {
83
- id: string;
84
- end(body?: {
85
- metadata?: Record<string, unknown>;
86
- usage?: unknown;
87
- output?: unknown;
88
- costDetails?: unknown;
89
- }): void;
90
- }
91
-
92
- interface LangfuseClient {
93
- trace(body?: { name: string; metadata?: Record<string, unknown>; input?: unknown; output?: unknown; sessionId?: string }): {
94
- id: string;
95
- update(body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }): void;
96
- };
97
- span(body: { name: string; traceId: string; metadata?: Record<string, unknown>; input?: unknown }): LangfuseSpan;
98
- generation(body: {
99
- name: string;
100
- traceId: string;
101
- metadata?: Record<string, unknown>;
102
- input?: unknown;
103
- output?: unknown;
104
- usage?: unknown;
105
- model?: string;
106
- costDetails?: unknown;
107
- }): LangfuseGeneration;
108
- score(body: { name: string; value: number; traceId?: string; observationId?: string }): void;
109
- shutdownAsync(): Promise<void>;
110
- }
111
-
112
- let client: LangfuseClient | null = null;
113
- let config: Config | null = loadConfigFromFile() ?? loadConfigFromEnv();
114
- let setupAttemptedThisSession = false;
115
-
116
- async function getClient(): Promise<LangfuseClient> {
117
- if (!config) {
118
- throw new Error("Langfuse config is not set");
119
- }
120
-
121
- if (!client) {
122
- const lib = await import(`${EXT_DIR}/node_modules/langfuse/lib/index.mjs`) as {
123
- Langfuse: new (options: { publicKey: string; secretKey?: string; baseUrl?: string }) => LangfuseClient;
124
- };
125
- client = new lib.Langfuse({
126
- publicKey: config.publicKey,
127
- secretKey: config.secretKey,
128
- baseUrl: config.host,
129
- });
130
- }
131
- return client;
132
- }
133
-
134
- // ============================================
135
- // State
136
- // ============================================
137
-
138
- interface TraceData {
139
- id: string;
140
- update?: (body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }) => void;
141
- }
142
-
143
- interface SpanData {
144
- span: LangfuseSpan;
145
- }
146
-
147
- let currentTrace: TraceData | null = null;
148
- let currentUserPrompt: string = "";
149
- let currentSessionId: string = "";
150
- let currentModel: string = "";
151
- let currentProvider: string = "";
152
- const activeSpans: Map<string, SpanData> = new Map();
153
-
154
- // Evaluation tracking state
155
- let toolCallCount: number = 0;
156
- let errorCount: number = 0;
157
- let turnCount: number = 0;
158
-
159
- function truncate(value: string, maxLength: number): string {
160
- return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
161
- }
162
-
163
- function safeSerialize(value: unknown, maxLength: number): string {
164
- try {
165
- return truncate(JSON.stringify(value, null, 2), maxLength);
166
- } catch {
167
- return `[unserializable ${typeof value}]`;
168
- }
169
- }
170
-
171
- function extractTextContent(
172
- content: Array<{ type: string; text?: string; thinking?: string }> | undefined,
173
- maxLength?: number,
174
- ): string | undefined {
175
- if (!content?.length) {
176
- return undefined;
177
- }
178
-
179
- const text = content
180
- .filter((item) => item.type === "text" && item.text)
181
- .map((item) => item.text)
182
- .join("\n");
183
-
184
- if (!text) {
185
- return undefined;
186
- }
187
-
188
- return maxLength ? truncate(text, maxLength) : text;
189
- }
190
-
191
- function resetSessionState() {
192
- toolCallCount = 0;
193
- errorCount = 0;
194
- turnCount = 0;
195
- activeSpans.clear();
196
- currentTrace = null;
197
- currentUserPrompt = "";
198
- currentModel = "";
199
- currentProvider = "";
200
- }
201
-
202
- async function ensureConfig(ctx: any): Promise<boolean> {
203
- if (config) {
204
- return true;
205
- }
206
-
207
- if (setupAttemptedThisSession) {
208
- return false;
209
- }
210
- setupAttemptedThisSession = true;
211
-
212
- if (!ctx.hasUI) {
213
- console.log("📊 Langfuse: Missing config. Run this extension in Pi UI to complete setup, or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST.");
214
- return false;
215
- }
216
-
217
- ctx.ui.notify("Langfuse setup required. Enter your API keys to enable tracing.", "info");
218
-
219
- const publicKey = (await ctx.ui.input("Langfuse public key:", "pk-lf-..."))?.trim();
220
- if (!publicKey) {
221
- ctx.ui.notify("Langfuse setup cancelled.", "warning");
222
- return false;
223
- }
224
-
225
- const secretKey = (await ctx.ui.input("Langfuse secret key:", "sk-lf-..."))?.trim();
226
- if (!secretKey) {
227
- ctx.ui.notify("Langfuse setup cancelled.", "warning");
228
- return false;
229
- }
230
-
231
- const hostInput = (await ctx.ui.input("Langfuse host:", DEFAULT_LANGFUSE_HOST))?.trim();
232
- config = {
233
- publicKey,
234
- secretKey,
235
- host: hostInput || DEFAULT_LANGFUSE_HOST,
236
- };
237
-
238
- try {
239
- saveConfig(config);
240
- ctx.ui.notify(`Langfuse config saved to ${CONFIG_PATH}`, "info");
241
- return true;
242
- } catch (error) {
243
- console.warn("📊 Langfuse: Failed to save config.json", error);
244
- ctx.ui.notify("Failed to save Langfuse config.json. Check extension directory permissions.", "error");
245
- config = null;
246
- return false;
247
- }
248
- }
249
-
250
- async function promptForConfig(ctx: any): Promise<boolean> {
251
- setupAttemptedThisSession = false;
252
- config = null;
253
- client = null;
254
- return ensureConfig(ctx);
255
- }
256
-
257
- function computeEvaluationScores() {
258
- const toolSuccessRate = toolCallCount > 0
259
- ? (toolCallCount - errorCount) / toolCallCount
260
- : 1;
261
- const sessionHadErrors = errorCount > 0;
262
-
263
- return {
264
- tool_call_count: toolCallCount,
265
- turn_count: turnCount,
266
- total_tool_errors: errorCount,
267
- tool_success_rate: toolSuccessRate,
268
- session_had_errors: sessionHadErrors ? 1 : 0, // 1 for true, 0 for false
269
- };
270
- }
13
+ import { state, resetRunState } from "./src/state.js";
14
+ import { ensureConfig, promptForConfig } from "./src/config.js";
15
+ import { shutdownRuntime } from "./src/langfuse.js";
16
+ import { getMessageFromEvent, extractAssistantOutput } from "./src/utils.js";
17
+ import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
18
+ import { startTurnObservation, finishTurnObservation } from "./src/handlers/turn.js";
19
+ import {
20
+ startGeneration,
21
+ updateGenerationMetadata,
22
+ finishGenerationFromMessage,
23
+ createFallbackGenerationFromTurn,
24
+ recordTTFT,
25
+ } from "./src/handlers/generation.js";
26
+ import {
27
+ startToolObservation,
28
+ finishToolObservation,
29
+ closeDanglingObservations,
30
+ } from "./src/handlers/tool.js";
271
31
 
272
32
  // ============================================
273
33
  // Extension
274
34
  // ============================================
275
35
 
276
36
  export default async function (pi: ExtensionAPI) {
277
- if (config) {
278
- console.log("📊 Langfuse: Tracing enabled →", config.host);
37
+ if (state.config) {
38
+ console.log("📊 Langfuse: Tracing enabled →", state.config.host);
279
39
  } else {
280
40
  console.log("📊 Langfuse: Waiting for first-run setup");
281
41
  }
@@ -287,266 +47,124 @@ export default async function (pi: ExtensionAPI) {
287
47
  },
288
48
  });
289
49
 
290
- // Capture session ID on session start
291
50
  pi.on("session_start", async (_event, ctx) => {
292
- setupAttemptedThisSession = false;
51
+ state.setupAttemptedThisSession = false;
293
52
  await ensureConfig(ctx);
294
53
  const sessionFile = ctx.sessionManager.getSessionFile();
295
54
  if (sessionFile) {
296
- // Extract session ID from file path (format: 2026-04-25T13-23-54-756Z_<uuid>.jsonl)
297
- currentSessionId = basename(sessionFile, ".jsonl");
55
+ state.currentSessionId = basename(sessionFile, ".jsonl");
298
56
  }
299
- // Reset state for new session
300
- resetSessionState();
57
+ resetRunState();
301
58
  });
302
59
 
303
- // Capture model info on model select
304
- pi.on("model_select", async (event, ctx) => {
305
- currentModel = event.model?.id || '';
306
- currentProvider = event.model?.provider || '';
60
+ pi.on("model_select", async (event) => {
61
+ state.currentModel = event.model?.id || "";
62
+ state.currentProvider = event.model?.provider || "";
307
63
  });
308
64
 
309
- // Use before_agent_start to capture user prompt and create trace
310
65
  pi.on("before_agent_start", async (event, ctx) => {
311
- if (!(await ensureConfig(ctx))) {
312
- return;
313
- }
66
+ await startAgentRun(event, ctx);
67
+ });
314
68
 
315
- try {
316
- const lf = await getClient();
317
- const cwd = event.systemPromptOptions?.cwd || process.cwd();
318
- currentUserPrompt = event.prompt;
319
-
320
- // Fallback to ctx.model if not captured via model_select
321
- if (!currentModel && ctx.model) {
322
- currentModel = ctx.model.id || '';
323
- currentProvider = ctx.model.provider || '';
324
- }
325
-
326
- // Create trace with user input and session ID
327
- currentTrace = lf.trace({
328
- name: "pi-agent",
329
- input: event.prompt,
330
- metadata: {
331
- cwd,
332
- model: currentModel,
333
- provider: currentProvider
334
- },
335
- sessionId: currentSessionId || undefined
336
- });
337
- } catch (e) {
338
- console.warn("📊 Langfuse: Failed to create trace", e);
69
+ pi.on("agent_start", async (event, ctx) => {
70
+ if (!state.agentState?.root) {
71
+ await startAgentRun(event, ctx);
339
72
  }
340
73
  });
341
74
 
342
- pi.on("agent_end", async (event) => {
343
- if (currentTrace) {
344
- // Get final response/output
345
- const eventData = event as unknown as {
346
- messages?: Array<{
347
- role: string;
348
- content: Array<{ type: string; text?: string; thinking?: string }>;
349
- }>;
350
- };
351
- const messages = eventData.messages || [];
352
- const lastAssistant = messages.filter(m => m.role === "assistant").pop();
353
-
354
- // Extract text from content array (filter out thinking blocks)
355
- const output = extractTextContent(lastAssistant?.content);
356
-
357
- // Compute evaluation scores
358
- const scores = computeEvaluationScores();
359
-
360
- currentTrace.update?.({
361
- output: output || undefined,
362
- metadata: {
363
- completed: true,
364
- totalTools: toolCallCount,
365
- model: currentModel,
366
- provider: currentProvider,
367
- ...scores
368
- }
369
- });
370
-
371
- // Send evaluation scores to Langfuse
372
- try {
373
- const lf = await getClient();
374
-
375
- // Trace-level evaluation scores
376
- lf.score({ name: "tool_call_count", value: scores.tool_call_count, traceId: currentTrace.id });
377
- lf.score({ name: "turn_count", value: scores.turn_count, traceId: currentTrace.id });
378
- lf.score({ name: "total_tool_errors", value: scores.total_tool_errors, traceId: currentTrace.id });
379
- lf.score({ name: "tool_success_rate", value: scores.tool_success_rate, traceId: currentTrace.id });
380
- lf.score({ name: "session_had_errors", value: scores.session_had_errors, traceId: currentTrace.id });
381
-
382
- console.log("📊 Langfuse: Evaluation scores sent:", scores);
383
- } catch (e) {
384
- console.warn("📊 Langfuse: Failed to send evaluation scores", e);
385
- }
386
-
387
- currentTrace = null;
388
- }
389
-
390
- // Reset for next session
391
- resetSessionState();
392
-
393
- if (client) {
394
- await client.shutdownAsync();
395
- client = null;
396
- }
75
+ pi.on("turn_start", async (event) => {
76
+ await startTurnObservation(event);
397
77
  });
398
78
 
399
- // Track tool calls and create spans
400
- pi.on("tool_call", async (event) => {
401
- if (!currentTrace) return;
79
+ pi.on("before_provider_request", async (event) => {
80
+ await startGeneration(event);
81
+ });
402
82
 
403
- try {
404
- const lf = await getClient();
405
-
406
- // Increment tool call counter
407
- toolCallCount++;
408
-
409
- // Format input nicely
410
- const inputStr = event.input ? safeSerialize(event.input, 1000) : "";
411
-
412
- const span = lf.span({
413
- name: `tool:${event.toolName}`,
414
- traceId: currentTrace.id,
415
- input: inputStr,
416
- metadata: { tool: event.toolName }
417
- });
418
-
419
- activeSpans.set(event.toolCallId, { span });
420
- } catch (e) {
421
- console.warn("📊 Langfuse: Failed to create span", e);
83
+ pi.on("after_provider_response", async (event) => {
84
+ updateGenerationMetadata(event);
85
+ });
86
+
87
+ pi.on("message_update", async (event) => {
88
+ recordTTFT(event);
89
+ const message = getMessageFromEvent(event);
90
+ if (message?.role === "assistant" && state.agentState) {
91
+ state.agentState.latestAssistantOutput = extractAssistantOutput(message);
422
92
  }
423
93
  });
424
94
 
425
- // Track tool results and errors
95
+ pi.on("message_end", async (event) => {
96
+ await finishGenerationFromMessage(event);
97
+ });
98
+
99
+ pi.on("tool_execution_start", async (event) => {
100
+ await startToolObservation(event);
101
+ });
102
+
103
+ pi.on("tool_call", async (event) => {
104
+ await startToolObservation(event);
105
+ });
106
+
426
107
  pi.on("tool_result", async (event) => {
427
- const spanData = activeSpans.get(event.toolCallId);
428
- if (spanData) {
429
- const { span } = spanData;
430
-
431
- // Format output nicely
432
- const outputStr = extractTextContent(event.content, 2000);
433
-
434
- span.end({
435
- isError: event.isError,
436
- output: outputStr || undefined
437
- });
438
-
439
- // Track errors and send per-tool score
440
- if (event.isError) {
441
- errorCount++;
442
- try {
443
- const lf = await getClient();
444
- // Per-tool error score (observation level)
445
- lf.score({
446
- name: "tool_is_error",
447
- value: 1,
448
- traceId: currentTrace?.id,
449
- observationId: span.id,
450
- });
451
- } catch (e) {
452
- console.warn("📊 Langfuse: Failed to send tool error score", e);
453
- }
454
- }
455
-
456
- activeSpans.delete(event.toolCallId);
457
- }
108
+ await finishToolObservation(event);
109
+ });
110
+
111
+ pi.on("tool_execution_end", async (event) => {
112
+ await finishToolObservation(event);
458
113
  });
459
114
 
460
- // Track turns and generations
461
115
  pi.on("turn_end", async (event) => {
462
- if (!currentTrace) return;
116
+ state.turnCount++;
117
+ const message = getMessageFromEvent(event);
118
+ if (message?.role === "assistant") {
119
+ await createFallbackGenerationFromTurn(event, message);
120
+ await finishGenerationFromMessage(event);
121
+ }
122
+ finishTurnObservation(event);
123
+ });
463
124
 
464
- // Increment turn counter
465
- turnCount++;
125
+ pi.on("agent_end", async (event) => {
126
+ await finishAgentRun(event);
127
+ await shutdownRuntime();
128
+ });
466
129
 
467
- const eventData = event as unknown as {
468
- message?: {
469
- role: string;
470
- content: Array<{ type: string; text?: string }>;
471
- model?: string;
472
- cost?: { input: number; output: number; total: number };
473
- usage?: {
474
- input: number;
475
- output: number;
476
- cacheRead: number;
477
- cacheWrite: number;
478
- totalTokens: number;
479
- cost?: { input: number; output: number; total: number };
480
- };
481
- };
482
- toolResults?: Array<{ toolName: string; toolCallId: string }>;
483
- };
130
+ const handleSessionInterruption = (reason: string) => {
131
+ if (state.agentState?.root) {
132
+ closeDanglingObservations(reason);
133
+ state.agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
134
+ }
135
+ resetRunState();
136
+ };
484
137
 
485
- const message = eventData.message;
486
- if (!message || message.role !== "assistant") return;
138
+ pi.on("session_before_switch", async () => {
139
+ handleSessionInterruption("Session switched");
140
+ });
487
141
 
488
- const usage = message.usage;
489
- const modelId = message.model || currentModel;
490
- const provider = currentProvider;
491
- const cost = usage?.cost;
142
+ pi.on("session_before_fork", async () => {
143
+ handleSessionInterruption("Session forked");
144
+ });
492
145
 
493
- if (usage) {
146
+ pi.on("session_compact", async (event) => {
147
+ if (state.agentState?.root) {
148
+ const parent = state.agentState.activeTurn ?? state.agentState.root;
494
149
  try {
495
- const lf = await getClient();
496
-
497
- // Extract output text
498
- const outputText = extractTextContent(message.content)?.slice(0, 1000) || "";
499
-
500
- // Create generation for the LLM response with model info
501
- // Note: usage and costDetails go in the generation observation, NOT as scores
502
- const gen = lf.generation({
503
- name: "llm-response",
504
- traceId: currentTrace.id,
505
- input: currentUserPrompt.slice(0, 500),
506
- output: outputText,
507
- model: modelId,
508
- metadata: {
509
- provider: provider,
510
- inputTokens: usage.input || 0,
511
- outputTokens: usage.output || 0,
512
- cachedTokens: usage.cacheRead || 0,
513
- },
514
- usage: {
515
- input: usage.input || 0,
516
- output: usage.output || 0,
517
- total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
518
- },
519
- costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined
520
- });
521
- gen.end({
522
- costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined,
523
- usage: {
524
- input: usage.input || 0,
525
- output: usage.output || 0,
526
- total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
527
- }
528
- });
150
+ const observation = parent.startObservation ? parent.startObservation(
151
+ "session_compact",
152
+ {
153
+ level: "DEFAULT",
154
+ statusMessage: "Context was compacted",
155
+ metadata: { ...event }
156
+ },
157
+ { asType: "span" }
158
+ ) : undefined;
159
+ observation?.end();
529
160
  } catch (e) {
530
- console.warn("📊 Langfuse: Failed to create generation", e);
161
+ // ignore
531
162
  }
532
-
533
- // NOTE: We no longer send token counts or cost as scores
534
- // Those belong in usage/costDetails on the generation observation
535
- // Scores are for EVALUATION metrics (success rates, error counts, etc.)
536
163
  }
537
164
  });
538
165
 
539
166
  pi.on("session_shutdown", async () => {
540
- if (currentTrace) {
541
- if (currentTrace.update) {
542
- currentTrace.update({ metadata: { completed: true } });
543
- }
544
- currentTrace = null;
545
- }
546
- if (client) {
547
- await client.shutdownAsync();
548
- client = null;
549
- }
550
- resetSessionState();
167
+ handleSessionInterruption("Session shutdown before agent completed");
168
+ await shutdownRuntime();
551
169
  });
552
170
  }