pi-langfuse 1.5.6 → 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/README.md CHANGED
@@ -104,6 +104,27 @@ export LANGFUSE_CAPTURE_CWD=false
104
104
 
105
105
  All captured payloads are redacted before upload. The extension masks common API keys, bearer tokens, passwords, cookies, private keys, Langfuse keys, GitHub/npm/AWS-style tokens, and local absolute paths.
106
106
 
107
+ ### Payload limits
108
+
109
+ Before upload, payloads are shaped: strings are truncated and deeply nested or
110
+ very wide structures are trimmed. These caps keep traces small and protect the
111
+ Langfuse ingestion pipeline. Override any of them (no rebuild needed):
112
+
113
+ ```bash
114
+ export PI_LANGFUSE_MAX_STRING_LENGTH=12000 # per-string chars (system prompt, inputs)
115
+ export PI_LANGFUSE_MAX_TOOL_PAYLOAD_LENGTH=24000 # per tool input/output chars
116
+ export PI_LANGFUSE_MAX_DEPTH=6 # max nesting depth
117
+ export PI_LANGFUSE_MAX_ARRAY_ITEMS=50 # max array elements kept
118
+ export PI_LANGFUSE_MAX_OBJECT_KEYS=80 # max object keys kept
119
+ export PI_LANGFUSE_MAX_PAYLOAD_NODES=2000 # max total nodes per payload
120
+ ```
121
+
122
+ Set any limit to `0`, `off`, `none`, or `unlimited` to disable that cap
123
+ entirely (captures the full value). Unset or invalid values fall back to the
124
+ defaults shown above. To capture a very large system prompt or big tool
125
+ payloads in full, raise or disable the relevant limit (e.g.
126
+ `PI_LANGFUSE_MAX_STRING_LENGTH=off`).
127
+
107
128
  ### Method 3: Persistent `config.json`
108
129
 
109
130
  Create or update `~/.pi/agent/pi-langfuse/config.json`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import { CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
5
5
  import { state } from "./state.js";
6
6
  import { forceShutdownRuntime } from "./langfuse.js";
7
7
  import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
8
+ import { createPayloadLimits } from "./limits.js";
8
9
 
9
10
  export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.env as EnvLike): Config | null {
10
11
  if (existsSync(path)) {
@@ -22,6 +23,7 @@ export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.en
22
23
  secretKey: config.secretKey,
23
24
  host: config.host || DEFAULT_LANGFUSE_HOST,
24
25
  capturePolicy: createCapturePolicy(captureSource),
26
+ limits: createPayloadLimits(env),
25
27
  };
26
28
  }
27
29
  } catch (e) {
@@ -44,6 +46,7 @@ export function loadConfigFromEnv(env: EnvLike = process.env as EnvLike): Config
44
46
  secretKey,
45
47
  host: env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
46
48
  capturePolicy: createCapturePolicy(env),
49
+ limits: createPayloadLimits(env),
47
50
  };
48
51
  }
49
52
 
@@ -1,7 +1,7 @@
1
1
  import { state, resetRunState, computeEvaluationScores } from "../state.js";
2
2
  import { getRuntime, sendScore } from "../langfuse.js";
3
3
  import { ensureConfig } from "../config.js";
4
- import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput, getCapturePolicy } from "../utils.js";
4
+ import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput, getCapturePolicy, getLimits } from "../utils.js";
5
5
  import { closeDanglingObservations } from "./tool.js";
6
6
  import { applyCapturePolicy } from "../capture-policy.js";
7
7
  import { collectSourceMetadata } from "../source-metadata.js";
@@ -79,7 +79,7 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
79
79
  ...(state.currentProvider ? { provider: state.currentProvider } : {}),
80
80
  sessionId: state.currentSessionId || undefined,
81
81
  },
82
- systemPrompt: systemPrompt ? truncate(String(systemPrompt), 20000) : undefined,
82
+ systemPrompt: systemPrompt ? truncate(String(systemPrompt), getLimits().maxString) : undefined,
83
83
  },
84
84
  getCapturePolicy(),
85
85
  );
@@ -10,8 +10,8 @@ import {
10
10
  truncate,
11
11
  estimatePayloadBytes,
12
12
  getCapturePolicy,
13
+ getLimits,
13
14
  } from "../utils.js";
14
- import { MAX_TOOL_PAYLOAD_LENGTH } from "../constants.js";
15
15
  import { applyCapturePolicy } from "../capture-policy.js";
16
16
  import { redactString } from "../redaction.js";
17
17
 
@@ -28,7 +28,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
28
28
  try {
29
29
  const toolName = getToolName(event);
30
30
  const toolInput = getToolInput(event);
31
- const shapedInput = shapePayload(toolInput, { maxString: MAX_TOOL_PAYLOAD_LENGTH });
31
+ const shapedInput = shapePayload(toolInput, { maxString: getLimits().maxToolPayload });
32
32
  const captured = applyCapturePolicy(
33
33
  {
34
34
  toolInput: shapedInput,
@@ -36,7 +36,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
36
36
  },
37
37
  getCapturePolicy(),
38
38
  );
39
- const inputBytes = estimatePayloadBytes(captured.toolInput, MAX_TOOL_PAYLOAD_LENGTH);
39
+ const inputBytes = estimatePayloadBytes(captured.toolInput, getLimits().maxToolPayload);
40
40
  const parent = state.agentState.activeTurn ?? state.agentState.root;
41
41
  const tool = await startChildObservation({
42
42
  parent,
@@ -79,7 +79,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
79
79
 
80
80
  const isError = Boolean(event.isError ?? event.error ?? event.status === "error");
81
81
  const output =
82
- extractTextContent(event.content, MAX_TOOL_PAYLOAD_LENGTH) ??
82
+ extractTextContent(event.content, getLimits().maxToolPayload) ??
83
83
  event.output ??
84
84
  event.result ??
85
85
  event.error ??
@@ -87,7 +87,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
87
87
  event;
88
88
 
89
89
  try {
90
- const shapedOutput = shapePayload(output, { maxString: MAX_TOOL_PAYLOAD_LENGTH });
90
+ const shapedOutput = shapePayload(output, { maxString: getLimits().maxToolPayload });
91
91
  const captured = applyCapturePolicy(
92
92
  {
93
93
  toolOutput: shapedOutput,
@@ -99,7 +99,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
99
99
  },
100
100
  getCapturePolicy(),
101
101
  );
102
- const outputBytes = estimatePayloadBytes(captured.toolOutput, MAX_TOOL_PAYLOAD_LENGTH);
102
+ const outputBytes = estimatePayloadBytes(captured.toolOutput, getLimits().maxToolPayload);
103
103
  const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
104
104
 
105
105
  activeTool.observation
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/limits.ts ADDED
@@ -0,0 +1,97 @@
1
+ import {
2
+ MAX_ARRAY_ITEMS,
3
+ MAX_DEPTH,
4
+ MAX_OBJECT_KEYS,
5
+ MAX_PAYLOAD_NODES,
6
+ MAX_STRING_LENGTH,
7
+ MAX_TOOL_PAYLOAD_LENGTH,
8
+ } from "./constants.js";
9
+ import type { EnvLike } from "./capture-policy.js";
10
+ import { state } from "./state.js";
11
+
12
+ /**
13
+ * Payload-shaping limits. Every field is a positive integer, or
14
+ * `Number.POSITIVE_INFINITY` to disable that limit entirely (capture everything).
15
+ * Resolved once from the environment and stored on the loaded config; consumers
16
+ * read the resolved values via `getLimits()` rather than the raw constants.
17
+ */
18
+ export interface PayloadLimits {
19
+ /** Max characters kept per captured string (generation/agent inputs, outputs, system prompt). */
20
+ readonly maxString: number;
21
+ /** Max characters kept for tool inputs/outputs (their payloads run larger than chat strings). */
22
+ readonly maxToolPayload: number;
23
+ /** Max nesting depth walked when shaping a structured payload. */
24
+ readonly maxDepth: number;
25
+ /** Max array elements kept per array. */
26
+ readonly maxArrayItems: number;
27
+ /** Max own-keys kept per object. */
28
+ readonly maxObjectKeys: number;
29
+ /** Max total nodes visited across a whole payload before bailing with `[payload too large]`. */
30
+ readonly maxNodes: number;
31
+ }
32
+
33
+ export const DEFAULT_LIMITS: PayloadLimits = {
34
+ maxString: MAX_STRING_LENGTH,
35
+ maxToolPayload: MAX_TOOL_PAYLOAD_LENGTH,
36
+ maxDepth: MAX_DEPTH,
37
+ maxArrayItems: MAX_ARRAY_ITEMS,
38
+ maxObjectKeys: MAX_OBJECT_KEYS,
39
+ maxNodes: MAX_PAYLOAD_NODES,
40
+ };
41
+
42
+ /** Words that mean "no limit" when supplied as an env value. */
43
+ const UNLIMITED_WORDS = new Set(["off", "none", "false", "no", "unlimited", "inf", "infinity"]);
44
+
45
+ /**
46
+ * Parse one limit env value.
47
+ * - unset / blank / unparseable -> `fallback` (the built-in default)
48
+ * - "off"/"none"/"unlimited"/... or a value <= 0 -> `Infinity` (limit removed)
49
+ * - a positive number -> that integer
50
+ */
51
+ export function parseLimit(raw: string | undefined, fallback: number): number {
52
+ if (raw === undefined) {
53
+ return fallback;
54
+ }
55
+ const trimmed = raw.trim().toLowerCase();
56
+ if (trimmed === "") {
57
+ return fallback;
58
+ }
59
+ if (UNLIMITED_WORDS.has(trimmed)) {
60
+ return Number.POSITIVE_INFINITY;
61
+ }
62
+ const parsed = Number(trimmed);
63
+ if (!Number.isFinite(parsed)) {
64
+ return fallback;
65
+ }
66
+ if (parsed <= 0) {
67
+ return Number.POSITIVE_INFINITY;
68
+ }
69
+ return Math.floor(parsed);
70
+ }
71
+
72
+ /**
73
+ * Resolve payload limits from the environment. Each `PI_LANGFUSE_MAX_*` var overrides
74
+ * the corresponding default; set any to `0`/`off`/`unlimited` to remove that limit.
75
+ * Namespaced `PI_LANGFUSE_*` (not `LANGFUSE_*`) to avoid clashing with Langfuse
76
+ * server env vars such as `LANGFUSE_MAX_EVENT_SIZE_BYTES`.
77
+ */
78
+ export function createPayloadLimits(env: EnvLike = process.env as EnvLike): PayloadLimits {
79
+ return {
80
+ maxString: parseLimit(env.PI_LANGFUSE_MAX_STRING_LENGTH, DEFAULT_LIMITS.maxString),
81
+ maxToolPayload: parseLimit(env.PI_LANGFUSE_MAX_TOOL_PAYLOAD_LENGTH, DEFAULT_LIMITS.maxToolPayload),
82
+ maxDepth: parseLimit(env.PI_LANGFUSE_MAX_DEPTH, DEFAULT_LIMITS.maxDepth),
83
+ maxArrayItems: parseLimit(env.PI_LANGFUSE_MAX_ARRAY_ITEMS, DEFAULT_LIMITS.maxArrayItems),
84
+ maxObjectKeys: parseLimit(env.PI_LANGFUSE_MAX_OBJECT_KEYS, DEFAULT_LIMITS.maxObjectKeys),
85
+ maxNodes: parseLimit(env.PI_LANGFUSE_MAX_PAYLOAD_NODES, DEFAULT_LIMITS.maxNodes),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Resolved limits for the current session: the config-loaded values when a
91
+ * config is active, otherwise a fresh resolve from the environment. Every
92
+ * capture/redaction path reads limits through this so a single env change
93
+ * (or config) governs truncation everywhere.
94
+ */
95
+ export function getLimits(): PayloadLimits {
96
+ return state.config?.limits ?? createPayloadLimits();
97
+ }
package/src/redaction.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { getLimits } from "./limits.js";
2
3
 
3
4
  export const REDACTED = "[REDACTED_SECRET]";
4
5
 
@@ -9,12 +10,15 @@ export interface RedactOptions {
9
10
  maxStringLength: number;
10
11
  }
11
12
 
12
- const DEFAULT_OPTIONS: RedactOptions = {
13
- maxDepth: 6,
14
- maxArrayItems: 50,
15
- maxObjectKeys: 80,
16
- maxStringLength: 12_000,
17
- };
13
+ function defaultOptions(): RedactOptions {
14
+ const limits = getLimits();
15
+ return {
16
+ maxDepth: limits.maxDepth,
17
+ maxArrayItems: limits.maxArrayItems,
18
+ maxObjectKeys: limits.maxObjectKeys,
19
+ maxStringLength: limits.maxString,
20
+ };
21
+ }
18
22
 
19
23
  const SECRET_ASSIGNMENT_RE =
20
24
  /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASS|API[_-]?KEY|PRIVATE[_-]?KEY|AUTH|COOKIE)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
@@ -36,7 +40,7 @@ function truncate(value: string, maxStringLength: number): string {
36
40
  }
37
41
 
38
42
  export function redactString(value: string, options: Partial<RedactOptions> = {}): string {
39
- const merged = { ...DEFAULT_OPTIONS, ...options };
43
+ const merged = { ...defaultOptions(), ...options };
40
44
  const truncated = truncate(value, merged.maxStringLength);
41
45
  return truncated
42
46
  .replace(PRIVATE_KEY_RE, REDACTED)
@@ -110,6 +114,6 @@ function visit(value: unknown, options: RedactOptions, depth: number, seen: Weak
110
114
  }
111
115
 
112
116
  export function redactValue(value: unknown, options: Partial<RedactOptions> = {}): unknown {
113
- const merged: RedactOptions = { ...DEFAULT_OPTIONS, ...options };
117
+ const merged: RedactOptions = { ...defaultOptions(), ...options };
114
118
  return visit(value, merged, merged.maxDepth, new WeakSet<object>());
115
119
  }
package/src/types.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { CapturePolicy } from "./capture-policy.js";
2
+ import type { PayloadLimits } from "./limits.js";
2
3
 
3
4
  export interface Config {
4
5
  publicKey: string;
5
6
  secretKey: string;
6
7
  host: string;
7
8
  capturePolicy?: CapturePolicy;
9
+ limits?: PayloadLimits;
8
10
  }
9
11
 
10
12
  export interface LangfuseObservation {
@@ -57,6 +59,22 @@ export interface LangfuseScoreClient {
57
59
  shutdown?: () => Promise<void>;
58
60
  }
59
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
+
60
78
  export interface LangfuseRuntime {
61
79
  startObservation: (
62
80
  name: string,
@@ -77,6 +95,15 @@ export interface LangfuseRuntime {
77
95
  tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
78
96
  clearTracerProvider?: () => void;
79
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;
80
107
  }
81
108
 
82
109
  export interface GenerationState {
package/src/utils.ts CHANGED
@@ -1,11 +1,4 @@
1
- import {
2
- MAX_ARRAY_ITEMS,
3
- MAX_DEPTH,
4
- MAX_OBJECT_KEYS,
5
- MAX_PAYLOAD_NODES,
6
- MAX_STRING_LENGTH,
7
- MAX_TOOL_PAYLOAD_LENGTH,
8
- } from "./constants.js";
1
+ import { getLimits } from "./limits.js";
9
2
  import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
10
3
  import { redactValue } from "./redaction.js";
11
4
  import { state } from "./state.js";
@@ -14,7 +7,9 @@ export function getCapturePolicy(): CapturePolicy {
14
7
  return state.config?.capturePolicy ?? createCapturePolicy();
15
8
  }
16
9
 
17
- export function truncate(value: string, maxLength = MAX_STRING_LENGTH): string {
10
+ export { getLimits };
11
+
12
+ export function truncate(value: string, maxLength = getLimits().maxString): string {
18
13
  return value.length > maxLength ? `${value.slice(0, maxLength)}... [truncated]` : value;
19
14
  }
20
15
 
@@ -35,11 +30,22 @@ const PAYLOAD_TOO_LARGE = "[payload too large]";
35
30
 
36
31
  export function shapePayload(
37
32
  value: unknown,
38
- options: { maxString?: number; depth?: number; maxNodes?: number; redact?: boolean; parseJson?: boolean } = {},
33
+ options: {
34
+ maxString?: number;
35
+ depth?: number;
36
+ maxNodes?: number;
37
+ maxArrayItems?: number;
38
+ maxObjectKeys?: number;
39
+ redact?: boolean;
40
+ parseJson?: boolean;
41
+ } = {},
39
42
  ): unknown {
40
- const maxString = options.maxString ?? MAX_STRING_LENGTH;
41
- const depth = options.depth ?? MAX_DEPTH;
42
- const maxNodes = options.maxNodes ?? MAX_PAYLOAD_NODES;
43
+ const limits = getLimits();
44
+ const maxString = options.maxString ?? limits.maxString;
45
+ const depth = options.depth ?? limits.maxDepth;
46
+ const maxNodes = options.maxNodes ?? limits.maxNodes;
47
+ const maxArrayItems = options.maxArrayItems ?? limits.maxArrayItems;
48
+ const maxObjectKeys = options.maxObjectKeys ?? limits.maxObjectKeys;
43
49
  const budget = { exhausted: false, nodeCount: 0 };
44
50
 
45
51
  function visit(item: unknown, remainingDepth: number, seen: WeakSet<object>): unknown {
@@ -88,7 +94,7 @@ export function shapePayload(
88
94
 
89
95
  if (Array.isArray(item)) {
90
96
  const output: unknown[] = [];
91
- const limit = Math.min(item.length, MAX_ARRAY_ITEMS);
97
+ const limit = Math.min(item.length, maxArrayItems);
92
98
  for (let index = 0; index < limit; index++) {
93
99
  output.push(visit(item[index], remainingDepth - 1, seen));
94
100
  if (budget.exhausted) {
@@ -120,7 +126,7 @@ export function shapePayload(
120
126
  }
121
127
  output[key] = visit((item as Record<string, unknown>)[key], remainingDepth - 1, seen);
122
128
  keyCount++;
123
- if (budget.exhausted || keyCount >= MAX_OBJECT_KEYS) {
129
+ if (budget.exhausted || keyCount >= maxObjectKeys) {
124
130
  break;
125
131
  }
126
132
  }
@@ -136,12 +142,12 @@ export function shapePayload(
136
142
  : redactValue(shaped, {
137
143
  maxDepth: depth,
138
144
  maxStringLength: maxString,
139
- maxArrayItems: MAX_ARRAY_ITEMS,
140
- maxObjectKeys: MAX_OBJECT_KEYS,
145
+ maxArrayItems,
146
+ maxObjectKeys,
141
147
  });
142
148
  }
143
149
 
144
- export function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGTH): string {
150
+ export function safeSerialize(value: unknown, maxLength = getLimits().maxToolPayload): string {
145
151
  try {
146
152
  return truncate(JSON.stringify(shapePayload(value, { maxString: maxLength }), null, 2), maxLength);
147
153
  } catch {
@@ -149,7 +155,7 @@ export function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGT
149
155
  }
150
156
  }
151
157
 
152
- export function estimatePayloadBytes(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGTH): number {
158
+ export function estimatePayloadBytes(value: unknown, maxLength = getLimits().maxToolPayload): number {
153
159
  return new TextEncoder().encode(safeSerialize(value, maxLength)).length;
154
160
  }
155
161