pi-langfuse 1.4.4 → 1.4.7

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
@@ -13,6 +13,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { state, resetRunState, runWithSession, setCurrentSession } from "./src/state.js";
14
14
  import { ensureConfig, promptForConfig, loadConfig } from "./src/config.js";
15
15
  import { shutdownRuntime } from "./src/langfuse.js";
16
+ import { handleLangfusePrivacyCommand, handleLangfuseTestCommand } from "./src/commands.js";
16
17
  import { getMessageFromEvent, extractAssistantOutput, getCapturePolicy } from "./src/utils.js";
17
18
  import { applyCapturePolicy } from "./src/capture-policy.js";
18
19
  import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
@@ -52,6 +53,20 @@ export default async function (pi: ExtensionAPI) {
52
53
  },
53
54
  });
54
55
 
56
+ pi.registerCommand("langfuse-test", {
57
+ description: "Send a test trace to Langfuse to verify configuration",
58
+ handler: async (args, ctx) => {
59
+ await handleLangfuseTestCommand(String(args ?? ""), ctx);
60
+ },
61
+ });
62
+
63
+ pi.registerCommand("langfuse-privacy", {
64
+ description: "View or set Langfuse telemetry privacy preset",
65
+ handler: async (args, ctx) => {
66
+ await handleLangfusePrivacyCommand(String(args ?? ""), ctx);
67
+ },
68
+ });
69
+
55
70
  const getSessionId = (ctx?: any) => {
56
71
  try {
57
72
  const sessionFile = ctx?.sessionManager?.getSessionFile?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.4.4",
3
+ "version": "1.4.7",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,8 @@
24
24
  "tsconfig.json"
25
25
  ],
26
26
  "scripts": {
27
- "typecheck": "tsc --noEmit"
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "tsx --test test/*.test.ts"
28
29
  },
29
30
  "keywords": [
30
31
  "pi-package",
@@ -60,6 +61,7 @@
60
61
  "node": ">=22"
61
62
  },
62
63
  "devDependencies": {
64
+ "tsx": "^4.19.0",
63
65
  "typescript": "^6.0.3"
64
66
  }
65
67
  }
@@ -0,0 +1,284 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ import { CONFIG_PATH } from "./constants.js";
4
+ import { loadConfig, saveConfig, ensureConfig } from "./config.js";
5
+ import { createCapturePolicy, type PrivacyPreset, type CapturePolicy } from "./capture-policy.js";
6
+ import { getRuntime, forceShutdownRuntime as shutdownLangfuseRuntime } from "./langfuse.js";
7
+ import { state } from "./state.js";
8
+ import type { LangfuseRuntime } from "./types.js";
9
+
10
+ const PRIVACY_PRESETS = ["metadata-only", "prompts-only", "conversations", "full-debug"] as const;
11
+
12
+ export interface CommandContextLike {
13
+ hasUI?: boolean;
14
+ ui?: {
15
+ notify?: (message: string, level?: "info" | "warning" | "error") => void;
16
+ select?: (title: string, options: string[]) => Promise<string | undefined>;
17
+ };
18
+ }
19
+
20
+ interface CommandDeps {
21
+ configPath?: string;
22
+ getRuntime?: () => Promise<LangfuseRuntime>;
23
+ forceShutdownRuntime?: () => Promise<void>;
24
+ }
25
+
26
+ function notify(ctx: CommandContextLike, message: string, level: "info" | "warning" | "error" = "info") {
27
+ if (ctx.hasUI && ctx.ui?.notify) {
28
+ ctx.ui.notify(message, level);
29
+ return;
30
+ }
31
+ const prefix = level === "error" ? "❌" : level === "warning" ? "⚠️" : "📊";
32
+ console.log(`${prefix} Langfuse: ${message}`);
33
+ }
34
+
35
+ function parseCommandArgs(args: string): { values: Record<string, string>; positional: string[]; malformed: string[] } {
36
+ const values: Record<string, string> = {};
37
+ const positional: string[] = [];
38
+ const malformed: string[] = [];
39
+
40
+ for (const part of args.trim().split(/\s+/)) {
41
+ if (!part) {
42
+ continue;
43
+ }
44
+ const eq = part.indexOf("=");
45
+ if (eq === -1) {
46
+ positional.push(part);
47
+ continue;
48
+ }
49
+ if (eq === 0) {
50
+ malformed.push(part);
51
+ continue;
52
+ }
53
+ values[part.slice(0, eq)] = part.slice(eq + 1);
54
+ }
55
+
56
+ return { values, positional, malformed };
57
+ }
58
+
59
+ function isPrivacyPreset(value: string | undefined): value is PrivacyPreset {
60
+ return PRIVACY_PRESETS.includes(value as PrivacyPreset);
61
+ }
62
+
63
+ function inferPreset(policy: CapturePolicy): PrivacyPreset | "custom" {
64
+ const entries: Array<[PrivacyPreset, CapturePolicy]> = [
65
+ [
66
+ "metadata-only",
67
+ {
68
+ captureInputs: false,
69
+ captureOutputs: false,
70
+ captureToolIo: false,
71
+ captureSystemPrompt: false,
72
+ captureCwd: false,
73
+ },
74
+ ],
75
+ [
76
+ "prompts-only",
77
+ {
78
+ captureInputs: true,
79
+ captureOutputs: false,
80
+ captureToolIo: false,
81
+ captureSystemPrompt: false,
82
+ captureCwd: false,
83
+ },
84
+ ],
85
+ [
86
+ "conversations",
87
+ {
88
+ captureInputs: true,
89
+ captureOutputs: true,
90
+ captureToolIo: false,
91
+ captureSystemPrompt: false,
92
+ captureCwd: false,
93
+ },
94
+ ],
95
+ [
96
+ "full-debug",
97
+ {
98
+ captureInputs: true,
99
+ captureOutputs: true,
100
+ captureToolIo: true,
101
+ captureSystemPrompt: true,
102
+ captureCwd: true,
103
+ },
104
+ ],
105
+ ];
106
+
107
+ for (const [preset, presetPolicy] of entries) {
108
+ if (
109
+ policy.captureInputs === presetPolicy.captureInputs &&
110
+ policy.captureOutputs === presetPolicy.captureOutputs &&
111
+ policy.captureToolIo === presetPolicy.captureToolIo &&
112
+ policy.captureSystemPrompt === presetPolicy.captureSystemPrompt &&
113
+ policy.captureCwd === presetPolicy.captureCwd
114
+ ) {
115
+ return preset;
116
+ }
117
+ }
118
+ return "custom";
119
+ }
120
+
121
+ function describePolicy(policy: CapturePolicy) {
122
+ return [
123
+ `captureInputs: ${policy.captureInputs}`,
124
+ `captureOutputs: ${policy.captureOutputs}`,
125
+ `captureToolIo: ${policy.captureToolIo}`,
126
+ `captureSystemPrompt: ${policy.captureSystemPrompt}`,
127
+ `captureCwd: ${policy.captureCwd}`,
128
+ ].join("\n");
129
+ }
130
+
131
+ function readPersistedConfig(path: string) {
132
+ if (!existsSync(path)) {
133
+ return {};
134
+ }
135
+ try {
136
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
137
+ } catch {
138
+ return {};
139
+ }
140
+ }
141
+
142
+ function hasActiveAgentObservation() {
143
+ for (const sessionState of state.sessionStates.values()) {
144
+ if (sessionState.agentState?.root) {
145
+ return true;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+
151
+ function savePrivacyPreset(
152
+ requestedPreset: PrivacyPreset,
153
+ ctx: CommandContextLike,
154
+ configPath: string,
155
+ ): boolean {
156
+ const existing = readPersistedConfig(configPath);
157
+ const loaded = state.config ?? loadConfig(process.env, configPath);
158
+
159
+ const publicKey = existing.publicKey ?? loaded?.publicKey;
160
+ const secretKey = existing.secretKey ?? loaded?.secretKey;
161
+ const host = existing.host ?? loaded?.host;
162
+
163
+ if (!publicKey || !secretKey || !host) {
164
+ notify(ctx, "Langfuse is not configured yet. Run /langfuse-setup before changing privacy settings.", "warning");
165
+ return false;
166
+ }
167
+
168
+ const nextConfig = {
169
+ publicKey: String(publicKey),
170
+ secretKey: String(secretKey),
171
+ host: String(host),
172
+ privacyPreset: requestedPreset,
173
+ };
174
+ saveConfig(nextConfig, configPath);
175
+ state.config = loadConfig(process.env, configPath);
176
+
177
+ notify(ctx, `Langfuse privacy preset saved: ${requestedPreset}\n${describePolicy(state.config?.capturePolicy ?? createCapturePolicy())}`);
178
+ return true;
179
+ }
180
+
181
+ export async function handleLangfusePrivacyCommand(
182
+ args: string,
183
+ ctx: CommandContextLike,
184
+ deps: CommandDeps = {},
185
+ ): Promise<boolean> {
186
+ const configPath = deps.configPath ?? CONFIG_PATH;
187
+ const parsed = parseCommandArgs(args);
188
+ if (parsed.malformed.length > 0) {
189
+ notify(ctx, `Couldn't understand '${parsed.malformed[0]}'. Use /langfuse-privacy preset=metadata-only.`, "warning");
190
+ return false;
191
+ }
192
+
193
+ const requestedPreset = parsed.values.preset ?? parsed.positional[0];
194
+ if (!requestedPreset) {
195
+ state.config = state.config ?? loadConfig(process.env, configPath);
196
+ const policy = state.config?.capturePolicy ?? createCapturePolicy();
197
+ if (ctx.hasUI && ctx.ui?.select) {
198
+ const currentPreset = inferPreset(policy);
199
+ const selectedPreset = await ctx.ui.select(
200
+ `Langfuse privacy preset (current: ${currentPreset})`,
201
+ [...PRIVACY_PRESETS],
202
+ );
203
+ if (!selectedPreset) {
204
+ notify(ctx, `Current Langfuse privacy preset: ${currentPreset}\n${describePolicy(policy)}`);
205
+ return true;
206
+ }
207
+ if (!isPrivacyPreset(selectedPreset)) {
208
+ notify(ctx, `Unknown privacy preset '${selectedPreset}'. Use one of: ${PRIVACY_PRESETS.join(", ")}.`, "warning");
209
+ return false;
210
+ }
211
+ return savePrivacyPreset(selectedPreset, ctx, configPath);
212
+ }
213
+ notify(ctx, `Current Langfuse privacy preset: ${inferPreset(policy)}\n${describePolicy(policy)}`);
214
+ return true;
215
+ }
216
+
217
+ if (!isPrivacyPreset(requestedPreset)) {
218
+ notify(
219
+ ctx,
220
+ `Unknown privacy preset '${requestedPreset}'. Use one of: ${PRIVACY_PRESETS.join(", ")}.`,
221
+ "warning",
222
+ );
223
+ return false;
224
+ }
225
+
226
+ return savePrivacyPreset(requestedPreset, ctx, configPath);
227
+ }
228
+
229
+ export async function handleLangfuseTestCommand(
230
+ _args: string,
231
+ ctx: CommandContextLike,
232
+ deps: CommandDeps = {},
233
+ ): Promise<boolean> {
234
+ if (!state.config && !(await ensureConfig(ctx))) {
235
+ notify(ctx, "Langfuse is not configured yet. Run /langfuse-setup first.", "warning");
236
+ return false;
237
+ }
238
+
239
+ if (hasActiveAgentObservation()) {
240
+ notify(ctx, "Langfuse test skipped because an agent run is active. Try again after the run finishes.", "warning");
241
+ return false;
242
+ }
243
+
244
+ let runtimeInitialized = false;
245
+ try {
246
+ const rt = await (deps.getRuntime ?? getRuntime)();
247
+ runtimeInitialized = true;
248
+ rt.propagateAttributes(
249
+ {
250
+ traceName: "pi-langfuse-test",
251
+ metadata: {
252
+ source: "pi-langfuse",
253
+ command: "langfuse-test",
254
+ },
255
+ },
256
+ () => {
257
+ const observation = rt.startObservation(
258
+ "pi-langfuse-test",
259
+ {
260
+ input: { command: "/langfuse-test" },
261
+ output: "ok",
262
+ metadata: {
263
+ source: "pi-langfuse",
264
+ command: "langfuse-test",
265
+ },
266
+ },
267
+ { asType: "span" },
268
+ );
269
+ observation.end();
270
+ return observation;
271
+ },
272
+ );
273
+ notify(ctx, `Langfuse test succeeded. Test trace sent to ${state.config?.host}.`);
274
+ return true;
275
+ } catch (error) {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ notify(ctx, `Langfuse test failed: ${message}`, "error");
278
+ return false;
279
+ } finally {
280
+ if (runtimeInitialized) {
281
+ await (deps.forceShutdownRuntime ?? shutdownLangfuseRuntime)();
282
+ }
283
+ }
284
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import type { Config } from "./types.js";
3
- import { CONFIG_DIR, CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
4
+ import { CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
4
5
  import { state } from "./state.js";
5
6
  import { forceShutdownRuntime } from "./langfuse.js";
6
7
  import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
@@ -50,9 +51,9 @@ export function loadConfig(env: EnvLike = process.env as EnvLike, path = CONFIG_
50
51
  return loadConfigFromFile(path, env) || loadConfigFromEnv(env);
51
52
  }
52
53
 
53
- export function saveConfig(config: Config) {
54
- mkdirSync(CONFIG_DIR, { recursive: true });
55
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
54
+ export function saveConfig(config: Config, path = CONFIG_PATH) {
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
56
57
  }
57
58
 
58
59
  async function collectConfigFromUI(ctx: any, reason: string): Promise<Config | null> {
package/src/langfuse.ts CHANGED
@@ -45,6 +45,9 @@ interface RestFallbackStore {
45
45
 
46
46
  const OTEL_VISIBILITY_TIMEOUT_MS = 1_500;
47
47
  const OTEL_VISIBILITY_POLL_INTERVAL_MS = 200;
48
+ const DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
49
+
50
+ let shutdownStepTimeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS;
48
51
 
49
52
  function nowIso() {
50
53
  return new Date().toISOString();
@@ -54,6 +57,29 @@ function delay(ms: number) {
54
57
  return new Promise((resolve) => setTimeout(resolve, ms));
55
58
  }
56
59
 
60
+ async function withTimeout<T>(label: string, operation: Promise<T> | undefined): Promise<T | undefined> {
61
+ if (!operation) {
62
+ return undefined;
63
+ }
64
+
65
+ let timeout: NodeJS.Timeout | undefined;
66
+ try {
67
+ return await Promise.race([
68
+ operation,
69
+ new Promise<undefined>((resolve) => {
70
+ timeout = setTimeout(() => {
71
+ console.log(`📊 Langfuse: ${label} timed out after ${shutdownStepTimeoutMs}ms`);
72
+ resolve(undefined);
73
+ }, shutdownStepTimeoutMs);
74
+ }),
75
+ ]);
76
+ } finally {
77
+ if (timeout) {
78
+ clearTimeout(timeout);
79
+ }
80
+ }
81
+ }
82
+
57
83
  function toIso(value: unknown): string | undefined {
58
84
  if (!value) {
59
85
  return undefined;
@@ -185,7 +211,10 @@ async function traceExists(rt: LangfuseRuntime, traceId: string): Promise<boolea
185
211
  if (!getTrace) {
186
212
  return false;
187
213
  }
188
- await getTrace(traceId);
214
+ const trace = await withTimeout("Trace visibility check", getTrace(traceId));
215
+ if (!trace) {
216
+ return false;
217
+ }
189
218
  return true;
190
219
  } catch {
191
220
  return false;
@@ -272,21 +301,34 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime) {
272
301
  });
273
302
  }
274
303
 
275
- const response = await rt.scoreClient.api?.ingestion?.batch?.({
276
- batch,
277
- metadata: {
278
- source: "pi-langfuse",
279
- fallback: "rest-ingestion",
280
- reason: "otel-trace-not-visible-after-flush",
281
- },
282
- });
304
+ const ingestionBatch = rt.scoreClient.api?.ingestion?.batch;
305
+ if (!ingestionBatch) {
306
+ console.log("📊 Langfuse: REST fallback ingestion is unavailable");
307
+ return;
308
+ }
309
+
310
+ const response = await withTimeout(
311
+ "REST fallback ingestion",
312
+ ingestionBatch({
313
+ batch,
314
+ metadata: {
315
+ source: "pi-langfuse",
316
+ fallback: "rest-ingestion",
317
+ reason: "otel-trace-not-visible-after-flush",
318
+ },
319
+ }),
320
+ );
321
+
322
+ if (!response) {
323
+ return;
324
+ }
283
325
 
284
326
  const responseBody = response as { errors?: unknown[] } | undefined;
285
327
  const errors = Array.isArray(responseBody?.errors) ? responseBody.errors : [];
286
328
  if (errors.length > 0) {
287
329
  console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
288
330
  } else {
289
- console.warn(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
331
+ console.log(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
290
332
  }
291
333
  }
292
334
 
@@ -356,11 +398,11 @@ function doShutdownRuntime(): Promise<void> {
356
398
  runtime = null;
357
399
 
358
400
  try {
359
- await rt.tracerProvider?.forceFlush?.();
401
+ await withTimeout("OTel force flush", rt.tracerProvider?.forceFlush?.());
360
402
  await fallbackToRestIngestion(rt);
361
- await rt.scoreClient.flush?.();
362
- await rt.scoreClient.shutdown?.();
363
- await rt.tracerProvider?.shutdown?.();
403
+ await withTimeout("Langfuse score flush", rt.scoreClient.flush?.());
404
+ await withTimeout("Langfuse client shutdown", rt.scoreClient.shutdown?.());
405
+ await withTimeout("OTel tracer shutdown", rt.tracerProvider?.shutdown?.());
364
406
  } catch (e) {
365
407
  console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
366
408
  } finally {
@@ -400,6 +442,12 @@ export async function forceShutdownRuntime(): Promise<void> {
400
442
  await doShutdownRuntime();
401
443
  }
402
444
 
445
+ export function __setRuntimeForTest(rt: LangfuseRuntime | null, timeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS): void {
446
+ runtime = rt;
447
+ shutdownStepTimeoutMs = timeoutMs;
448
+ activeSessions.clear();
449
+ }
450
+
403
451
  export async function sendScore(name: string, value: number, options: { traceId?: string; observationId?: string } = {}) {
404
452
  try {
405
453
  const rt = await getRuntime();