pi-langfuse 1.4.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.4.5",
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
  }
package/src/commands.ts CHANGED
@@ -13,6 +13,7 @@ export interface CommandContextLike {
13
13
  hasUI?: boolean;
14
14
  ui?: {
15
15
  notify?: (message: string, level?: "info" | "warning" | "error") => void;
16
+ select?: (title: string, options: string[]) => Promise<string | undefined>;
16
17
  };
17
18
  }
18
19
 
@@ -147,6 +148,36 @@ function hasActiveAgentObservation() {
147
148
  return false;
148
149
  }
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
+
150
181
  export async function handleLangfusePrivacyCommand(
151
182
  args: string,
152
183
  ctx: CommandContextLike,
@@ -163,6 +194,22 @@ export async function handleLangfusePrivacyCommand(
163
194
  if (!requestedPreset) {
164
195
  state.config = state.config ?? loadConfig(process.env, configPath);
165
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
+ }
166
213
  notify(ctx, `Current Langfuse privacy preset: ${inferPreset(policy)}\n${describePolicy(policy)}`);
167
214
  return true;
168
215
  }
@@ -176,29 +223,7 @@ export async function handleLangfusePrivacyCommand(
176
223
  return false;
177
224
  }
178
225
 
179
- const existing = readPersistedConfig(configPath);
180
- const loaded = state.config ?? loadConfig(process.env, configPath);
181
-
182
- const publicKey = existing.publicKey ?? loaded?.publicKey;
183
- const secretKey = existing.secretKey ?? loaded?.secretKey;
184
- const host = existing.host ?? loaded?.host;
185
-
186
- if (!publicKey || !secretKey || !host) {
187
- notify(ctx, "Langfuse is not configured yet. Run /langfuse-setup before changing privacy settings.", "warning");
188
- return false;
189
- }
190
-
191
- const nextConfig = {
192
- publicKey: String(publicKey),
193
- secretKey: String(secretKey),
194
- host: String(host),
195
- privacyPreset: requestedPreset,
196
- };
197
- saveConfig(nextConfig, configPath);
198
- state.config = loadConfig(process.env, configPath);
199
-
200
- notify(ctx, `Langfuse privacy preset saved: ${requestedPreset}\n${describePolicy(state.config?.capturePolicy ?? createCapturePolicy())}`);
201
- return true;
226
+ return savePrivacyPreset(requestedPreset, ctx, configPath);
202
227
  }
203
228
 
204
229
  export async function handleLangfuseTestCommand(
@@ -245,8 +270,6 @@ export async function handleLangfuseTestCommand(
245
270
  return observation;
246
271
  },
247
272
  );
248
- await rt.tracerProvider?.forceFlush?.();
249
- await rt.scoreClient.flush?.();
250
273
  notify(ctx, `Langfuse test succeeded. Test trace sent to ${state.config?.host}.`);
251
274
  return true;
252
275
  } catch (error) {
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();