pi-langfuse 1.5.14 → 1.5.16

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
@@ -136,6 +136,19 @@ defaults shown above. To capture a very large system prompt or big tool
136
136
  payloads in full, raise or disable the relevant limit (e.g.
137
137
  `PI_LANGFUSE_MAX_STRING_LENGTH=off`).
138
138
 
139
+ The REST fallback ingestion is chunked so each request body stays well below
140
+ the Langfuse gateway's payload limit (~4.5MB). These knobs control the chunk
141
+ budget and a hard ceiling for the whole fallback payload:
142
+
143
+ ```bash
144
+ export PI_LANGFUSE_MAX_INGESTION_BATCH_BYTES=4194304 # per-request body budget, default 4MB
145
+
146
+ export PI_LANGFUSE_MAX_FALLBACK_TOTAL_BYTES=33554432 # whole-payload ceiling, default 32MB
147
+ ```
148
+
149
+ When the accumulated fallback payload exceeds the 32MB ceiling, ingestion is
150
+ skipped with a warning instead of attempting an unrecoverably large upload.
151
+
139
152
  ### Method 3: Persistent `config.json`
140
153
 
141
154
  Create or update `~/.pi/agent/pi-langfuse/config.json`:
package/README_CN.md CHANGED
@@ -115,6 +115,34 @@ export LANGFUSE_CAPTURE_SOURCE_METADATA=false
115
115
 
116
116
  所有被采集的负载在上传前仍会脱敏。扩展会隐藏常见 API key、Bearer token、密码、Cookie、私钥、Langfuse key、GitHub/npm/AWS 风格 token,并对本地绝对路径做 hash。
117
117
 
118
+ ### 负载上限
119
+
120
+ 上传前会对负载做整形:字符串会被截断,过深或过宽的结构会被裁剪。这些上限让 trace 保持精简,同时保护
121
+ Langfuse 摄取管线。任何一项都可以覆盖(无需重新构建):
122
+
123
+ ```bash
124
+ export PI_LANGFUSE_MAX_STRING_LENGTH=12000 # 单字符串字符数(system prompt、输入)
125
+ export PI_LANGFUSE_MAX_TOOL_PAYLOAD_LENGTH=24000 # 工具输入/输出字符数
126
+ export PI_LANGFUSE_MAX_DEPTH=6 # 最大嵌套深度
127
+ export PI_LANGFUSE_MAX_ARRAY_ITEMS=50 # 数组保留的最大元素数
128
+ export PI_LANGFUSE_MAX_OBJECT_KEYS=80 # 对象保留的最大键数
129
+ export PI_LANGFUSE_MAX_PAYLOAD_NODES=2000 # 单个负载的最大总节点数
130
+ ```
131
+
132
+ 任意上限设置为 `0`、`off`、`none` 或 `unlimited` 即表示关闭该限制(采集完整值);未设置或非法值回退到上方默认值。
133
+ 若想完整采集很大的 system prompt 或工具负载,可以调高或关闭相关限制(例如 `PI_LANGFUSE_MAX_STRING_LENGTH=off`)。
134
+
135
+ REST 回退摄取会按字节切分,保证每个请求体都远低于 Langfuse 网关的负载限制(约 4.5MB)。以下变量控制切分预算
136
+ 与整个回退负载的硬上限:
137
+
138
+ ```bash
139
+ export PI_LANGFUSE_MAX_INGESTION_BATCH_BYTES=4194304 # 单次请求体预算,默认 4MB
140
+
141
+ export PI_LANGFUSE_MAX_FALLBACK_TOTAL_BYTES=33554432 # 整体负载上限,默认 32MB
142
+ ```
143
+
144
+ 当累积的回退负载超过 32MB 上限时,会跳过摄取并给出告警,而不是尝试一次注定失败的超大上传。
145
+
118
146
  ### 方式 3:持久化 `config.json`
119
147
 
120
148
  创建或更新 `~/.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.14",
3
+ "version": "1.5.16",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/langfuse.ts CHANGED
@@ -69,6 +69,13 @@ const DEFAULT_SCORE_FLUSH_AT = 10;
69
69
  const DEFAULT_SCORE_FLUSH_INTERVAL_MS = 1_000;
70
70
  const MAX_SCORE_QUEUE_SIZE = 100_000;
71
71
  const MAX_SCORE_BATCH_SIZE = 100;
72
+ // Langfuse gateways (incl. langfuse-dx.wair.ac.cn) reject ingestion bodies above
73
+ // ~4.5MB with HTTP 413. Keep the default chunk budget safely below that limit.
74
+ const DEFAULT_MAX_INGESTION_BATCH_BYTES = 4 * 1024 * 1024;
75
+ // Hard ceiling for the whole REST fallback payload. Above this the fallback is
76
+ // pointless (it would need dozens of requests during a bounded shutdown) and is
77
+ // skipped with a warning.
78
+ const DEFAULT_MAX_FALLBACK_TOTAL_BYTES = 32 * 1024 * 1024;
72
79
 
73
80
  let shutdownStepTimeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS;
74
81
 
@@ -91,6 +98,22 @@ function getScoreShutdownTimeoutMs(): number {
91
98
  ) * 1_000;
92
99
  }
93
100
 
101
+ function getMaxIngestionBatchBytes(): number {
102
+ return resolvePositiveEnvNumber(
103
+ "PI_LANGFUSE_MAX_INGESTION_BATCH_BYTES",
104
+ DEFAULT_MAX_INGESTION_BATCH_BYTES,
105
+ true,
106
+ );
107
+ }
108
+
109
+ function getMaxFallbackTotalBytes(): number {
110
+ return resolvePositiveEnvNumber(
111
+ "PI_LANGFUSE_MAX_FALLBACK_TOTAL_BYTES",
112
+ DEFAULT_MAX_FALLBACK_TOTAL_BYTES,
113
+ true,
114
+ );
115
+ }
116
+
94
117
  function delay(ms: number, signal?: AbortSignal) {
95
118
  return new Promise<void>((resolve, reject) => {
96
119
  if (signal?.aborted) {
@@ -244,6 +267,41 @@ async function ingestBatch(rt: LangfuseRuntime, batch: unknown[], signal: AbortS
244
267
  return Array.isArray(responseBody.errors) ? responseBody.errors : [];
245
268
  }
246
269
 
270
+ function serializedBytes(value: unknown): number {
271
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
272
+ }
273
+
274
+ /**
275
+ * Split an ingestion event list into chunks whose serialized request bodies each
276
+ * stay under `maxBytes`. Each chunk, when wrapped as `{ batch: chunk }`, matches
277
+ * the measurement exactly because entries are serialized individually and joined
278
+ * with single commas inside the `{"batch":[...]}` envelope.
279
+ */
280
+ export function splitIngestionBatch(entries: unknown[], maxBytes: number): unknown[][] {
281
+ const serialized = entries.map((entry) => JSON.stringify(entry));
282
+ const envelopeBytes = Buffer.byteLength('{"batch":[]}');
283
+ const chunks: unknown[][] = [];
284
+ let chunk: unknown[] = [];
285
+ let chunkBytes = envelopeBytes;
286
+
287
+ for (let index = 0; index < serialized.length; index++) {
288
+ const entryBytes = Buffer.byteLength(serialized[index], "utf8");
289
+ const separators = chunk.length > 0 ? 1 : 0;
290
+ const projectedBytes = chunkBytes + entryBytes + separators;
291
+ if (chunk.length > 0 && projectedBytes > maxBytes) {
292
+ chunks.push(chunk);
293
+ chunk = [];
294
+ chunkBytes = envelopeBytes;
295
+ }
296
+ chunk.push(entries[index]);
297
+ chunkBytes += entryBytes + (chunk.length > 1 ? 1 : 0);
298
+ }
299
+ if (chunk.length > 0) {
300
+ chunks.push(chunk);
301
+ }
302
+ return chunks;
303
+ }
304
+
247
305
  async function flushPendingScores(rt: LangfuseRuntime, signal: AbortSignal): Promise<void> {
248
306
  const pendingScores = rt.pendingScores;
249
307
  if (!pendingScores || pendingScores.length === 0) {
@@ -400,6 +458,32 @@ function observationType(asType?: string): FallbackObservationType {
400
458
  return asType === "generation" ? "GENERATION" : "SPAN";
401
459
  }
402
460
 
461
+ /**
462
+ * Langfuse stamps propagated attributes onto a span in
463
+ * `LangfuseSpanProcessor.onStart(span, parentContext)`, reading them from the
464
+ * OTel context that was active when the span was created. `propagateAttributes`
465
+ * only seeds that context for the duration of its callback, so a child created
466
+ * later — from a separate event handler, outside the callback — is written with
467
+ * an empty `session.id`.
468
+ *
469
+ * That is invisible in the legacy data model, where the session lives on the
470
+ * trace, but Langfuse v4 stores `session_id` per event row and aggregates a
471
+ * session with `WHERE session_id != ''`. Unstamped children drop out of
472
+ * `sumMap(cost_details)` and `sumMap(usage_details)`, which is why such
473
+ * sessions report their trace count correctly but no cost and no usage at all.
474
+ *
475
+ * Re-entering the propagated context for every child keeps the whole tree in
476
+ * the session. The id is read back off the parent span rather than from
477
+ * `state.currentSessionId`, so a child created outside an active session scope
478
+ * still inherits whatever its parent was actually stamped with.
479
+ */
480
+ const OTEL_SESSION_ID_ATTRIBUTE = "session.id";
481
+
482
+ function readSessionId(observation: any): string | undefined {
483
+ const value = observation?.otelSpan?.attributes?.[OTEL_SESSION_ID_ATTRIBUTE];
484
+ return typeof value === "string" && value ? value : undefined;
485
+ }
486
+
403
487
  function wrapObservation(
404
488
  observation: any,
405
489
  store: RestFallbackStore,
@@ -459,7 +543,12 @@ function wrapObservation(
459
543
  return observation.end();
460
544
  },
461
545
  startObservation(childName: string, childBody?: Record<string, unknown>, options?: { asType?: string }) {
462
- const child = observation.startObservation(childName, childBody, options);
546
+ const inheritedSessionId = readSessionId(observation) ?? state.currentSessionId ?? undefined;
547
+ const propagate = runtime?.propagateAttributes;
548
+ const createChild = () => observation.startObservation(childName, childBody, options);
549
+ const child = inheritedSessionId && propagate
550
+ ? propagate({ sessionId: inheritedSessionId.slice(0, 200) }, createChild)
551
+ : createChild();
463
552
  return wrapObservation(child, store, childName, childBody, options?.asType, id);
464
553
  },
465
554
  setTraceIO(traceBody?: { input?: unknown; output?: unknown }) {
@@ -587,7 +676,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
587
676
  }
588
677
 
589
678
  const trace = store.trace;
590
- const batch: any[] = [
679
+ const entries: any[] = [
591
680
  {
592
681
  type: "trace-create",
593
682
  id: randomUUID(),
@@ -627,7 +716,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
627
716
  }
628
717
  : {}),
629
718
  };
630
- batch.push({
719
+ entries.push({
631
720
  type: observation.type === "GENERATION" ? "generation-create" : "span-create",
632
721
  id: randomUUID(),
633
722
  timestamp: eventTimestamp(observation),
@@ -635,12 +724,39 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
635
724
  });
636
725
  }
637
726
 
638
- const errors = await ingestBatch(rt, batch, signal);
727
+ const maxTotalBytes = getMaxFallbackTotalBytes();
728
+ const totalBytes = serializedBytes({ batch: entries });
729
+ if (totalBytes > maxTotalBytes) {
730
+ const message = `REST fallback payload is ${(totalBytes / 1024 / 1024).toFixed(1)}MB, above the ${(maxTotalBytes / 1024 / 1024).toFixed(1)}MB ceiling; skipping fallback ingestion`;
731
+ rememberRuntimeError("REST fallback ingestion", new Error(message));
732
+ console.warn(`📊 Langfuse: ${message}`);
733
+ return;
734
+ }
735
+
736
+ const chunks = splitIngestionBatch(entries, getMaxIngestionBatchBytes());
737
+ const errors: unknown[] = [];
738
+ for (const chunk of chunks) {
739
+ if (signal.aborted) {
740
+ break;
741
+ }
742
+ try {
743
+ errors.push(...(await ingestBatch(rt, chunk, signal)));
744
+ } catch (error) {
745
+ if (signal.aborted && isAbortError(error)) {
746
+ break;
747
+ }
748
+ rememberRuntimeError("REST fallback ingestion", error);
749
+ console.warn("📊 Langfuse: REST fallback ingestion chunk failed", error);
750
+ errors.push(error instanceof Error ? error.message : String(error));
751
+ }
752
+ }
639
753
  if (errors.length > 0) {
640
754
  rememberRuntimeError("REST fallback ingestion", new Error(JSON.stringify(errors)));
641
755
  console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
642
756
  } else {
643
- debugLog(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
757
+ debugLog(
758
+ `📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion (${chunks.length} chunk(s))`,
759
+ );
644
760
  }
645
761
  }
646
762