pi-langfuse 1.5.7 → 1.5.8

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.5.7",
3
+ "version": "1.5.8",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/langfuse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LangfuseRuntime, LangfuseScoreClient } from "./types.js";
1
+ import type { LangfuseRuntime, LangfuseScoreClient, PendingScore } from "./types.js";
2
2
  import { state } from "./state.js";
3
3
  import { randomUUID } from "node:crypto";
4
4
 
@@ -60,6 +60,11 @@ interface RestFallbackStore {
60
60
  const OTEL_VISIBILITY_TIMEOUT_MS = 1_500;
61
61
  const OTEL_VISIBILITY_POLL_INTERVAL_MS = 200;
62
62
  const DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
63
+ const DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS = 5;
64
+ const DEFAULT_SCORE_FLUSH_AT = 10;
65
+ const DEFAULT_SCORE_FLUSH_INTERVAL_MS = 1_000;
66
+ const MAX_SCORE_QUEUE_SIZE = 100_000;
67
+ const MAX_SCORE_BATCH_SIZE = 100;
63
68
 
64
69
  let shutdownStepTimeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS;
65
70
 
@@ -67,8 +72,27 @@ function nowIso() {
67
72
  return new Date().toISOString();
68
73
  }
69
74
 
70
- function delay(ms: number) {
71
- return new Promise((resolve) => setTimeout(resolve, ms));
75
+ function resolvePositiveEnvNumber(name: string, fallback: number, integer = false): number {
76
+ const parsed = Number(process.env[name]);
77
+ if (!Number.isFinite(parsed) || parsed <= 0) {
78
+ return fallback;
79
+ }
80
+ return integer ? Math.floor(parsed) : parsed;
81
+ }
82
+
83
+ function delay(ms: number, signal?: AbortSignal) {
84
+ return new Promise<void>((resolve, reject) => {
85
+ if (signal?.aborted) {
86
+ reject(signal.reason);
87
+ return;
88
+ }
89
+
90
+ const timeout = setTimeout(resolve, ms);
91
+ signal?.addEventListener("abort", () => {
92
+ clearTimeout(timeout);
93
+ reject(signal.reason);
94
+ }, { once: true });
95
+ });
72
96
  }
73
97
 
74
98
  function debugLog(message: string) {
@@ -107,7 +131,14 @@ export function getLastRuntimeError(): { scope: string; message: string; timesta
107
131
  return lastRuntimeError;
108
132
  }
109
133
 
110
- async function withTimeout<T>(label: string, operation: Promise<T> | undefined): Promise<T | undefined> {
134
+ async function withShutdownDeadline<T>(label: string, startOperation: () => Promise<T> | undefined, deadline: number): Promise<T | undefined> {
135
+ const remainingMs = deadline - Date.now();
136
+ if (remainingMs <= 0) {
137
+ debugLog(`📊 Langfuse: Skipped ${label}; shutdown deadline elapsed`);
138
+ return undefined;
139
+ }
140
+
141
+ const operation = startOperation();
111
142
  if (!operation) {
112
143
  return undefined;
113
144
  }
@@ -118,9 +149,9 @@ async function withTimeout<T>(label: string, operation: Promise<T> | undefined):
118
149
  operation,
119
150
  new Promise<undefined>((resolve) => {
120
151
  timeout = setTimeout(() => {
121
- debugLog(`📊 Langfuse: ${label} timed out after ${shutdownStepTimeoutMs}ms`);
152
+ debugLog(`📊 Langfuse: ${label} timed out; shutdown deadline elapsed`);
122
153
  resolve(undefined);
123
- }, shutdownStepTimeoutMs);
154
+ }, remainingMs);
124
155
  }),
125
156
  ]);
126
157
  } finally {
@@ -130,6 +161,142 @@ async function withTimeout<T>(label: string, operation: Promise<T> | undefined):
130
161
  }
131
162
  }
132
163
 
164
+ function getRuntimeConfig(rt: LangfuseRuntime) {
165
+ return rt.runtimeConfig ?? state.config;
166
+ }
167
+
168
+ function ingestionHeaders(rt: LangfuseRuntime): Record<string, string> {
169
+ const config = getRuntimeConfig(rt);
170
+ if (!config) {
171
+ throw new Error("Langfuse runtime config is unavailable");
172
+ }
173
+
174
+ const auth = Buffer.from(`${config.publicKey}:${config.secretKey}`).toString("base64");
175
+ return {
176
+ Authorization: `Basic ${auth}`,
177
+ "Content-Type": "application/json",
178
+ };
179
+ }
180
+
181
+ async function ingestBatch(rt: LangfuseRuntime, batch: unknown[], signal: AbortSignal): Promise<unknown[]> {
182
+ const config = getRuntimeConfig(rt);
183
+ if (!config) {
184
+ throw new Error("Langfuse runtime config is unavailable");
185
+ }
186
+
187
+ const response = await fetch(`${config.host.replace(/\/$/, "")}/api/public/ingestion`, {
188
+ method: "POST",
189
+ headers: ingestionHeaders(rt),
190
+ body: JSON.stringify({ batch }),
191
+ signal,
192
+ });
193
+ if (!response.ok) {
194
+ throw new Error(`Langfuse ingestion failed with HTTP ${response.status}`);
195
+ }
196
+
197
+ const text = await response.text();
198
+ if (!text) {
199
+ return [];
200
+ }
201
+
202
+ const responseBody = JSON.parse(text) as { errors?: unknown[] };
203
+ return Array.isArray(responseBody.errors) ? responseBody.errors : [];
204
+ }
205
+
206
+ async function flushPendingScores(rt: LangfuseRuntime, signal: AbortSignal): Promise<void> {
207
+ const pendingScores = rt.pendingScores;
208
+ if (!pendingScores || pendingScores.length === 0) {
209
+ return;
210
+ }
211
+
212
+ while (pendingScores.length > 0) {
213
+ const scores = pendingScores.slice(0, MAX_SCORE_BATCH_SIZE);
214
+ try {
215
+ const errors = await ingestBatch(
216
+ rt,
217
+ scores.map((score) => ({
218
+ type: "score-create",
219
+ id: randomUUID(),
220
+ timestamp: nowIso(),
221
+ body: score,
222
+ })),
223
+ signal,
224
+ );
225
+ pendingScores.splice(0, scores.length);
226
+ if (errors.length > 0) {
227
+ rememberRuntimeError("score ingestion", new Error(JSON.stringify(errors)));
228
+ console.warn("📊 Langfuse: Score ingestion reported errors", errors);
229
+ }
230
+ } catch (error) {
231
+ if ((error as { name?: string }).name !== "AbortError") {
232
+ rememberRuntimeError("score ingestion", error);
233
+ console.warn("📊 Langfuse: Failed to flush scores", error);
234
+ }
235
+ return;
236
+ }
237
+ }
238
+ }
239
+
240
+ function clearScoreFlushTimer(rt: LangfuseRuntime) {
241
+ if (rt.scoreFlushTimer) {
242
+ clearTimeout(rt.scoreFlushTimer);
243
+ rt.scoreFlushTimer = undefined;
244
+ }
245
+ }
246
+
247
+ function scheduleScoreFlush(rt: LangfuseRuntime) {
248
+ if (
249
+ rt.scoreFlushStopped
250
+ || rt.scoreFlushTimer
251
+ || rt.scoreFlushPromise
252
+ || !rt.pendingScores?.length
253
+ ) {
254
+ return;
255
+ }
256
+
257
+ rt.scoreFlushTimer = setTimeout(() => {
258
+ rt.scoreFlushTimer = undefined;
259
+ void startScoreFlush(rt);
260
+ }, rt.scoreFlushIntervalMs ?? DEFAULT_SCORE_FLUSH_INTERVAL_MS);
261
+ rt.scoreFlushTimer.unref?.();
262
+ }
263
+
264
+ function startScoreFlush(rt: LangfuseRuntime): Promise<void> {
265
+ if (rt.scoreFlushPromise) {
266
+ return rt.scoreFlushPromise;
267
+ }
268
+
269
+ clearScoreFlushTimer(rt);
270
+ const controller = new AbortController();
271
+ const timeout = setTimeout(
272
+ () => controller.abort(new DOMException("Langfuse score request timed out", "AbortError")),
273
+ rt.scoreRequestTimeoutMs ?? DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS * 1_000,
274
+ );
275
+ timeout.unref?.();
276
+ rt.scoreFlushController = controller;
277
+
278
+ const promise = flushPendingScores(rt, controller.signal).finally(() => {
279
+ clearTimeout(timeout);
280
+ if (rt.scoreFlushPromise === promise) {
281
+ rt.scoreFlushPromise = undefined;
282
+ }
283
+ if (rt.scoreFlushController === controller) {
284
+ rt.scoreFlushController = undefined;
285
+ }
286
+ scheduleScoreFlush(rt);
287
+ });
288
+ rt.scoreFlushPromise = promise;
289
+ return promise;
290
+ }
291
+
292
+ function stopScoreFlush(rt: LangfuseRuntime) {
293
+ rt.scoreFlushStopped = true;
294
+ clearScoreFlushTimer(rt);
295
+ rt.scoreFlushController?.abort(
296
+ new DOMException("Langfuse score flushing stopped", "AbortError"),
297
+ );
298
+ }
299
+
133
300
  function toIso(value: unknown): string | undefined {
134
301
  if (!value) {
135
302
  return undefined;
@@ -258,27 +425,40 @@ function wrapObservation(
258
425
  };
259
426
  }
260
427
 
261
- async function traceExists(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
428
+ async function traceExists(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
429
+ const config = getRuntimeConfig(rt);
430
+ if (!config) {
431
+ return false;
432
+ }
433
+
262
434
  try {
263
- const traceApi = rt.scoreClient.api?.trace;
264
- if (!traceApi?.get) {
435
+ const response = await fetch(
436
+ `${config.host.replace(/\/$/, "")}/api/public/traces/${encodeURIComponent(traceId)}`,
437
+ {
438
+ headers: ingestionHeaders(rt),
439
+ signal,
440
+ },
441
+ );
442
+ if (response.status === 404) {
265
443
  return false;
266
444
  }
267
- const trace = await withTimeout("Trace visibility check", traceApi.get(traceId));
268
- if (!trace) {
269
- return false;
445
+ if (!response.ok) {
446
+ throw new Error(`Langfuse trace visibility check failed with HTTP ${response.status}`);
270
447
  }
271
448
  return true;
272
- } catch {
449
+ } catch (error) {
450
+ if (signal.aborted) {
451
+ throw error;
452
+ }
273
453
  return false;
274
454
  }
275
455
  }
276
456
 
277
- async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
457
+ async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
278
458
  const deadline = Date.now() + OTEL_VISIBILITY_TIMEOUT_MS;
279
459
 
280
460
  while (true) {
281
- if (await traceExists(rt, traceId)) {
461
+ if (await traceExists(rt, traceId, signal)) {
282
462
  return true;
283
463
  }
284
464
 
@@ -287,7 +467,7 @@ async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string): Pro
287
467
  return false;
288
468
  }
289
469
 
290
- await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs));
470
+ await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs), signal);
291
471
  }
292
472
  }
293
473
 
@@ -295,14 +475,14 @@ function eventTimestamp(record: { endTime?: string; startTime?: string; timestam
295
475
  return record.endTime ?? record.startTime ?? record.timestamp ?? nowIso();
296
476
  }
297
477
 
298
- async function fallbackToRestIngestion(rt: LangfuseRuntime) {
478
+ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal) {
299
479
  const store = rt.restFallback as RestFallbackStore | undefined;
300
480
  if (!store?.trace || store.attempted) {
301
481
  return;
302
482
  }
303
483
  store.attempted = true;
304
484
 
305
- if (await waitForTraceVisibility(rt, store.trace.id)) {
485
+ if (await waitForTraceVisibility(rt, store.trace.id, signal)) {
306
486
  return;
307
487
  }
308
488
 
@@ -355,31 +535,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime) {
355
535
  });
356
536
  }
357
537
 
358
- const ingestionApi = rt.scoreClient.api?.ingestion;
359
- if (!ingestionApi?.batch) {
360
- debugLog("📊 Langfuse: REST fallback ingestion is unavailable");
361
- return;
362
- }
363
-
364
- const response = await withTimeout(
365
- "REST fallback ingestion",
366
- ingestionApi.batch({
367
- batch,
368
- metadata: {
369
- source: "pi-langfuse",
370
- fallback: "rest-ingestion",
371
- reason: "otel-trace-not-visible-after-flush",
372
- },
373
- }),
374
- );
375
-
376
- if (!response) {
377
- return;
378
- }
379
-
380
- const responseBody = response as { errors?: unknown[] } | undefined;
381
- const responseErrors = responseBody?.errors;
382
- const errors = Array.isArray(responseErrors) ? responseErrors : [];
538
+ const errors = await ingestBatch(rt, batch, signal);
383
539
  if (errors.length > 0) {
384
540
  rememberRuntimeError("REST fallback ingestion", new Error(JSON.stringify(errors)));
385
541
  console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
@@ -426,6 +582,11 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
426
582
 
427
583
  try {
428
584
  ensureOtelContextManager(context, AsyncHooksContextManager);
585
+ const scoreFlushAt = resolvePositiveEnvNumber("LANGFUSE_FLUSH_AT", DEFAULT_SCORE_FLUSH_AT, true);
586
+ const scoreFlushIntervalMs =
587
+ resolvePositiveEnvNumber("LANGFUSE_FLUSH_INTERVAL", DEFAULT_SCORE_FLUSH_INTERVAL_MS / 1_000) * 1_000;
588
+ const scoreRequestTimeoutMs =
589
+ resolvePositiveEnvNumber("LANGFUSE_TIMEOUT", DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS) * 1_000;
429
590
  const spanProcessor = new LangfuseSpanProcessor({
430
591
  publicKey: state.config.publicKey,
431
592
  secretKey: state.config.secretKey,
@@ -449,6 +610,16 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
449
610
  tracerProvider,
450
611
  clearTracerProvider: () => tracing.setLangfuseTracerProvider(null),
451
612
  restFallback,
613
+ pendingScores: [],
614
+ scoreFlushAt,
615
+ scoreFlushIntervalMs,
616
+ scoreRequestTimeoutMs,
617
+ scoreFlushStopped: false,
618
+ runtimeConfig: {
619
+ publicKey: state.config.publicKey,
620
+ secretKey: state.config.secretKey,
621
+ host: state.config.host,
622
+ },
452
623
  };
453
624
  lastRuntimeError = null;
454
625
  } catch (e) {
@@ -468,17 +639,36 @@ function doShutdownRuntime(): Promise<void> {
468
639
 
469
640
  const rt = runtime;
470
641
  runtime = null;
642
+ const deadline = Date.now() + shutdownStepTimeoutMs;
643
+ const controller = new AbortController();
644
+ const abortTimeout = setTimeout(() => controller.abort(), shutdownStepTimeoutMs);
645
+ stopScoreFlush(rt);
471
646
 
472
647
  try {
473
- await withTimeout("OTel force flush", rt.tracerProvider?.forceFlush?.());
474
- await fallbackToRestIngestion(rt);
475
- await withTimeout("Langfuse score flush", rt.scoreClient.flush?.());
476
- await withTimeout("Langfuse client shutdown", rt.scoreClient.shutdown?.());
477
- await withTimeout("OTel tracer shutdown", rt.tracerProvider?.shutdown?.());
648
+ await withShutdownDeadline(
649
+ "Active score flush",
650
+ () => rt.scoreFlushPromise,
651
+ deadline,
652
+ );
653
+ await withShutdownDeadline("OTel force flush", () => rt.tracerProvider?.forceFlush?.(), deadline);
654
+ await withShutdownDeadline(
655
+ "REST fallback ingestion",
656
+ () => fallbackToRestIngestion(rt, controller.signal),
657
+ deadline,
658
+ );
659
+ await flushPendingScores(rt, controller.signal);
660
+ await withShutdownDeadline("Langfuse score flush", () => rt.scoreClient.flush?.(), deadline);
661
+ await withShutdownDeadline("Langfuse client shutdown", () => rt.scoreClient.shutdown?.(), deadline);
662
+ await withShutdownDeadline("OTel tracer shutdown", () => rt.tracerProvider?.shutdown?.(), deadline);
478
663
  } catch (e) {
479
664
  rememberRuntimeError("runtime shutdown", e);
480
665
  console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
481
666
  } finally {
667
+ clearTimeout(abortTimeout);
668
+ clearScoreFlushTimer(rt);
669
+ rt.scoreFlushController?.abort();
670
+ rt.scoreFlushController = undefined;
671
+ rt.scoreFlushPromise = undefined;
482
672
  if (!runtime) {
483
673
  rt.clearTracerProvider?.();
484
674
  }
@@ -516,7 +706,13 @@ export async function forceShutdownRuntime(): Promise<void> {
516
706
  }
517
707
 
518
708
  export function __setRuntimeForTest(rt: LangfuseRuntime | null, timeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS): void {
709
+ if (runtime && runtime !== rt) {
710
+ stopScoreFlush(runtime);
711
+ }
519
712
  runtime = rt;
713
+ if (rt) {
714
+ rt.scoreFlushStopped = false;
715
+ }
520
716
  shutdownStepTimeoutMs = timeoutMs;
521
717
  activeSessions.clear();
522
718
  }
@@ -524,14 +720,35 @@ export function __setRuntimeForTest(rt: LangfuseRuntime | null, timeoutMs = DEFA
524
720
  export async function sendScore(name: string, value: number, options: { traceId?: string; observationId?: string } = {}) {
525
721
  try {
526
722
  const rt = await getRuntime();
527
- rt.scoreClient.score?.create({
723
+ const score: PendingScore = {
528
724
  name,
529
725
  value,
530
726
  dataType: name === "session_had_errors" || name === "tool_is_error" ? "BOOLEAN" : "NUMERIC",
531
727
  traceId: options.traceId,
532
728
  observationId: options.observationId,
533
729
  sessionId: options.traceId ? undefined : state.currentSessionId || undefined,
534
- });
730
+ ...(process.env.LANGFUSE_TRACING_ENVIRONMENT
731
+ ? { environment: process.env.LANGFUSE_TRACING_ENVIRONMENT }
732
+ : {}),
733
+ };
734
+ if (!rt.pendingScores) {
735
+ return;
736
+ }
737
+ if (rt.pendingScores.length >= MAX_SCORE_QUEUE_SIZE) {
738
+ const error = new Error(
739
+ `Langfuse score queue is full (${MAX_SCORE_QUEUE_SIZE}); dropping score`,
740
+ );
741
+ rememberRuntimeError("score queue", error);
742
+ console.warn(`📊 Langfuse: ${error.message}`);
743
+ return;
744
+ }
745
+
746
+ rt.pendingScores.push(score);
747
+ if (rt.pendingScores.length >= (rt.scoreFlushAt ?? DEFAULT_SCORE_FLUSH_AT)) {
748
+ void startScoreFlush(rt);
749
+ } else {
750
+ scheduleScoreFlush(rt);
751
+ }
535
752
  } catch (e) {
536
753
  rememberRuntimeError(`score ${name}`, e);
537
754
  console.warn(`📊 Langfuse: Failed to send score ${name}`, e);
package/src/types.ts CHANGED
@@ -59,6 +59,22 @@ export interface LangfuseScoreClient {
59
59
  shutdown?: () => Promise<void>;
60
60
  }
61
61
 
62
+ export interface PendingScore {
63
+ traceId?: string;
64
+ sessionId?: string;
65
+ observationId?: string;
66
+ name: string;
67
+ value: number;
68
+ dataType?: "NUMERIC" | "BOOLEAN";
69
+ environment?: string;
70
+ }
71
+
72
+ export interface LangfuseRuntimeConfig {
73
+ publicKey: string;
74
+ secretKey: string;
75
+ host: string;
76
+ }
77
+
62
78
  export interface LangfuseRuntime {
63
79
  startObservation: (
64
80
  name: string,
@@ -79,6 +95,15 @@ export interface LangfuseRuntime {
79
95
  tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
80
96
  clearTracerProvider?: () => void;
81
97
  restFallback?: unknown;
98
+ pendingScores?: PendingScore[];
99
+ scoreFlushAt?: number;
100
+ scoreFlushIntervalMs?: number;
101
+ scoreRequestTimeoutMs?: number;
102
+ scoreFlushTimer?: NodeJS.Timeout;
103
+ scoreFlushPromise?: Promise<void>;
104
+ scoreFlushController?: AbortController;
105
+ scoreFlushStopped?: boolean;
106
+ runtimeConfig?: LangfuseRuntimeConfig;
82
107
  }
83
108
 
84
109
  export interface GenerationState {