pi-langfuse 1.5.10 → 1.5.12

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
@@ -14,7 +14,7 @@ Langfuse observability extension for [Pi Coding Agent](https://github.com/earend
14
14
  - Final assistant output capture, tool error visibility, and trace-level scores.
15
15
  - Privacy controls for inputs, outputs, tool I/O, system prompt, and cwd.
16
16
  - Secret redaction and local path hashing before upload.
17
- - REST fallback for self-hosted Langfuse setups where OTel spans arrive but traces do not materialize.
17
+ - Capability-gated REST fallback for self-hosted Langfuse setups that expose the legacy trace API when OTel spans arrive but traces do not materialize. Langfuse v4 `events_only` deployments use OTel without legacy fallback ingestion.
18
18
 
19
19
  ## Prerequisites
20
20
 
package/README_CN.md CHANGED
@@ -14,7 +14,7 @@
14
14
  - 记录最终助手输出、工具错误状态和追踪级别分数。
15
15
  - 提供输入、输出、工具 I/O、system prompt 和 cwd 的隐私采集开关。
16
16
  - 上传前脱敏常见密钥,并对本地绝对路径做 hash。
17
- - 针对自托管 Langfuse 提供 REST 兜底,覆盖 OTel span 已到达但 trace 未可见的场景。
17
+ - 对仍提供旧版 trace API 的自托管 Langfuse 启用能力检测后的 REST 兜底,覆盖 OTel span 已到达但 trace 未可见的场景;Langfuse v4 `events_only` 部署仅使用 OTel,不执行旧版 REST 兜底写入。
18
18
 
19
19
  ## 前提条件
20
20
 
package/index.ts CHANGED
@@ -16,7 +16,7 @@ import { shutdownRuntime } from "./src/langfuse.js";
16
16
  import { handleLangfusePrivacyCommand, handleLangfuseStatusCommand, handleLangfuseTestCommand } from "./src/commands.js";
17
17
  import { getMessageFromEvent, extractAssistantOutput, getCapturePolicy } from "./src/utils.js";
18
18
  import { applyCapturePolicy } from "./src/capture-policy.js";
19
- import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
19
+ import { startAgentRun, finishAgentRun, recordSystemPrompt } from "./src/handlers/agent.js";
20
20
  import { startTurnObservation, finishTurnObservation } from "./src/handlers/turn.js";
21
21
  import {
22
22
  startGeneration,
@@ -112,6 +112,9 @@ export default async function (pi: ExtensionAPI) {
112
112
  if (!state.agentState?.root) {
113
113
  await startAgentRun(event, ctx);
114
114
  }
115
+ // The system prompt is only final here: before_agent_start handlers that
116
+ // run after this extension may still rewrite it.
117
+ await recordSystemPrompt(ctx);
115
118
  }));
116
119
 
117
120
  pi.on("turn_start", async (event, ctx) => withSession(ctx, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.10",
3
+ "version": "1.5.12",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -54,15 +54,6 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
54
54
  state.currentProvider = ctx.model.provider || "";
55
55
  }
56
56
 
57
- let systemPrompt = undefined;
58
- try {
59
- if (ctx.getSystemPrompt) {
60
- systemPrompt = await ctx.getSystemPrompt();
61
- }
62
- } catch {
63
- // Ignore if getSystemPrompt is not available or fails
64
- }
65
-
66
57
  const rawPromptInput = shapePayload({
67
58
  prompt: event.prompt,
68
59
  images: event.images,
@@ -79,7 +70,6 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
79
70
  ...(state.currentProvider ? { provider: state.currentProvider } : {}),
80
71
  sessionId: state.currentSessionId || undefined,
81
72
  },
82
- systemPrompt: systemPrompt ? truncate(String(systemPrompt), getLimits().maxString) : undefined,
83
73
  },
84
74
  getCapturePolicy(),
85
75
  );
@@ -106,10 +96,7 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
106
96
  "pi-agent",
107
97
  {
108
98
  input: captured.input,
109
- metadata: {
110
- ...(captured.metadata ?? {}),
111
- ...(captured.systemPrompt ? { systemPrompt: captured.systemPrompt } : {}),
112
- },
99
+ metadata: captured.metadata ?? {},
113
100
  },
114
101
  { asType: "agent" },
115
102
  ),
@@ -124,6 +111,49 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
124
111
  }
125
112
  }
126
113
 
114
+ /**
115
+ * Records the effective system prompt on the root agent observation.
116
+ *
117
+ * Deliberately called from `agent_start` rather than `before_agent_start`:
118
+ * during `before_agent_start` the extension runner hands each handler the
119
+ * prompt as it stands mid-chain, so extensions registered after this one
120
+ * (e.g. inline factories that rewrite the system prompt) are not reflected
121
+ * yet. By `agent_start` the session has applied the final override, and
122
+ * `ctx.getSystemPrompt()` returns the prompt actually sent to the model.
123
+ */
124
+ export async function recordSystemPrompt(ctx: any) {
125
+ const root = state.agentState?.root;
126
+ if (state.isTracingDisabled || !root) {
127
+ return;
128
+ }
129
+
130
+ let systemPrompt = undefined;
131
+ try {
132
+ if (ctx.getSystemPrompt) {
133
+ systemPrompt = await ctx.getSystemPrompt();
134
+ }
135
+ } catch {
136
+ // Ignore if getSystemPrompt is not available or fails
137
+ }
138
+ if (!systemPrompt) {
139
+ return;
140
+ }
141
+
142
+ const captured = applyCapturePolicy(
143
+ { systemPrompt: truncate(String(systemPrompt), getLimits().maxString) },
144
+ getCapturePolicy(),
145
+ );
146
+ if (!captured.systemPrompt) {
147
+ return;
148
+ }
149
+
150
+ try {
151
+ root.update({ metadata: { systemPrompt: captured.systemPrompt } });
152
+ } catch (e) {
153
+ console.warn("\u{1F4CA} Langfuse: Failed to record system prompt", e);
154
+ }
155
+ }
156
+
127
157
  export async function finishAgentRun(event: Record<string, unknown> = {}) {
128
158
  if (!state.agentState?.root) {
129
159
  resetRunState();
package/src/langfuse.ts CHANGED
@@ -8,6 +8,8 @@ const activeSessions = new Set<string>();
8
8
  let lastRuntimeError: { scope: string; message: string; timestamp: Date } | null = null;
9
9
 
10
10
  type FallbackObservationType = "SPAN" | "GENERATION";
11
+ type LegacyTraceApiCapability = "supported" | "unsupported";
12
+ type TraceVisibility = "visible" | "missing" | "legacy-api-unsupported" | "unknown";
11
13
 
12
14
  interface OtelContextManager {
13
15
  enable(): OtelContextManager;
@@ -55,6 +57,7 @@ interface RestFallbackStore {
55
57
  observations: RestFallbackObservation[];
56
58
  observationById: Map<string, RestFallbackObservation>;
57
59
  attempted: boolean;
60
+ legacyTraceApi?: LegacyTraceApiCapability;
58
61
  }
59
62
 
60
63
  const OTEL_VISIBILITY_TIMEOUT_MS = 1_500;
@@ -139,7 +142,16 @@ export function getLastRuntimeError(): { scope: string; message: string; timesta
139
142
  return lastRuntimeError;
140
143
  }
141
144
 
142
- async function withShutdownDeadline<T>(label: string, startOperation: () => Promise<T> | undefined, deadline: number): Promise<T | undefined> {
145
+ function isAbortError(error: unknown): boolean {
146
+ return error instanceof Error && error.name === "AbortError";
147
+ }
148
+
149
+ async function withShutdownDeadline<T>(
150
+ label: string,
151
+ startOperation: () => Promise<T> | undefined,
152
+ deadline: number,
153
+ signal?: AbortSignal,
154
+ ): Promise<T | undefined> {
143
155
  const remainingMs = deadline - Date.now();
144
156
  if (remainingMs <= 0) {
145
157
  debugLog(`📊 Langfuse: Skipped ${label}; shutdown deadline elapsed`);
@@ -162,6 +174,12 @@ async function withShutdownDeadline<T>(label: string, startOperation: () => Prom
162
174
  }, remainingMs);
163
175
  }),
164
176
  ]);
177
+ } catch (error) {
178
+ if (signal?.aborted && isAbortError(error)) {
179
+ debugLog(`📊 Langfuse: ${label} aborted; shutdown deadline elapsed`);
180
+ return undefined;
181
+ }
182
+ throw error;
165
183
  } finally {
166
184
  if (timeout) {
167
185
  clearTimeout(timeout);
@@ -169,6 +187,21 @@ async function withShutdownDeadline<T>(label: string, startOperation: () => Prom
169
187
  }
170
188
  }
171
189
 
190
+ async function runShutdownStep<T>(
191
+ label: string,
192
+ startOperation: () => Promise<T> | undefined,
193
+ deadline: number,
194
+ signal?: AbortSignal,
195
+ ): Promise<T | undefined> {
196
+ try {
197
+ return await withShutdownDeadline(label, startOperation, deadline, signal);
198
+ } catch (error) {
199
+ rememberRuntimeError(`runtime shutdown: ${label}`, error);
200
+ console.warn(`📊 Langfuse: Failed ${label} during shutdown`, error);
201
+ return undefined;
202
+ }
203
+ }
204
+
172
205
  function getRuntimeConfig(rt: LangfuseRuntime) {
173
206
  return rt.runtimeConfig ?? state.config;
174
207
  }
@@ -436,10 +469,51 @@ function wrapObservation(
436
469
  };
437
470
  }
438
471
 
439
- async function traceExists(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
472
+ async function getLegacyTraceApiCapability(
473
+ rt: LangfuseRuntime,
474
+ store: RestFallbackStore,
475
+ signal: AbortSignal,
476
+ ): Promise<LegacyTraceApiCapability | undefined> {
477
+ if (store.legacyTraceApi) {
478
+ return store.legacyTraceApi;
479
+ }
480
+
481
+ const config = getRuntimeConfig(rt);
482
+ if (!config) {
483
+ return undefined;
484
+ }
485
+
486
+ try {
487
+ const response = await fetch(`${config.host.replace(/\/$/, "")}/api/public/traces?limit=1`, {
488
+ headers: ingestionHeaders(rt),
489
+ signal,
490
+ });
491
+ if (response.status === 404) {
492
+ store.legacyTraceApi = "unsupported";
493
+ return store.legacyTraceApi;
494
+ }
495
+ if (!response.ok) {
496
+ return undefined;
497
+ }
498
+ store.legacyTraceApi = "supported";
499
+ return store.legacyTraceApi;
500
+ } catch (error) {
501
+ if (signal.aborted) {
502
+ throw error;
503
+ }
504
+ return undefined;
505
+ }
506
+ }
507
+
508
+ async function getTraceVisibility(
509
+ rt: LangfuseRuntime,
510
+ store: RestFallbackStore,
511
+ traceId: string,
512
+ signal: AbortSignal,
513
+ ): Promise<TraceVisibility> {
440
514
  const config = getRuntimeConfig(rt);
441
515
  if (!config) {
442
- return false;
516
+ return "unknown";
443
517
  }
444
518
 
445
519
  try {
@@ -451,31 +525,41 @@ async function traceExists(rt: LangfuseRuntime, traceId: string, signal: AbortSi
451
525
  },
452
526
  );
453
527
  if (response.status === 404) {
454
- return false;
528
+ const capability = await getLegacyTraceApiCapability(rt, store, signal);
529
+ if (capability === "supported") {
530
+ return "missing";
531
+ }
532
+ return capability === "unsupported" ? "legacy-api-unsupported" : "unknown";
455
533
  }
456
534
  if (!response.ok) {
457
- throw new Error(`Langfuse trace visibility check failed with HTTP ${response.status}`);
535
+ return "unknown";
458
536
  }
459
- return true;
537
+ return "visible";
460
538
  } catch (error) {
461
539
  if (signal.aborted) {
462
540
  throw error;
463
541
  }
464
- return false;
542
+ return "unknown";
465
543
  }
466
544
  }
467
545
 
468
- async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
546
+ async function waitForTraceVisibility(
547
+ rt: LangfuseRuntime,
548
+ store: RestFallbackStore,
549
+ traceId: string,
550
+ signal: AbortSignal,
551
+ ): Promise<TraceVisibility> {
469
552
  const deadline = Date.now() + OTEL_VISIBILITY_TIMEOUT_MS;
470
553
 
471
554
  while (true) {
472
- if (await traceExists(rt, traceId, signal)) {
473
- return true;
555
+ const visibility = await getTraceVisibility(rt, store, traceId, signal);
556
+ if (visibility !== "missing") {
557
+ return visibility;
474
558
  }
475
559
 
476
560
  const remainingMs = deadline - Date.now();
477
561
  if (remainingMs <= 0) {
478
- return false;
562
+ return "missing";
479
563
  }
480
564
 
481
565
  await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs), signal);
@@ -493,7 +577,12 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
493
577
  }
494
578
  store.attempted = true;
495
579
 
496
- if (await waitForTraceVisibility(rt, store.trace.id, signal)) {
580
+ const visibility = await waitForTraceVisibility(rt, store, store.trace.id, signal);
581
+ if (visibility === "visible") {
582
+ return;
583
+ }
584
+ if (visibility !== "missing") {
585
+ debugLog(`📊 Langfuse: Skipped REST fallback; trace visibility is ${visibility}`);
497
586
  return;
498
587
  }
499
588
 
@@ -654,32 +743,37 @@ function doShutdownRuntime(): Promise<void> {
654
743
  const controller = new AbortController();
655
744
  const abortTimeout = setTimeout(() => controller.abort(), shutdownStepTimeoutMs);
656
745
  const scoreController = new AbortController();
746
+ const scoreShutdownTimeoutMs = Math.min(getScoreShutdownTimeoutMs(), shutdownStepTimeoutMs);
747
+ const scoreDeadline = Math.min(deadline, Date.now() + scoreShutdownTimeoutMs);
657
748
  const scoreAbortTimeout = setTimeout(
658
749
  () => scoreController.abort(),
659
- Math.min(getScoreShutdownTimeoutMs(), shutdownStepTimeoutMs),
750
+ scoreShutdownTimeoutMs,
660
751
  );
661
752
  scoreAbortTimeout.unref?.();
662
753
  stopScoreFlush(rt);
663
754
 
664
755
  try {
665
- await withShutdownDeadline(
756
+ await runShutdownStep(
666
757
  "Active score flush",
667
758
  () => rt.scoreFlushPromise,
668
759
  deadline,
669
760
  );
670
- await flushPendingScores(rt, scoreController.signal);
671
- await withShutdownDeadline("OTel force flush", () => rt.tracerProvider?.forceFlush?.(), deadline);
672
- await withShutdownDeadline(
761
+ await runShutdownStep(
762
+ "Pending score flush",
763
+ () => flushPendingScores(rt, scoreController.signal),
764
+ scoreDeadline,
765
+ scoreController.signal,
766
+ );
767
+ await runShutdownStep("OTel force flush", () => rt.tracerProvider?.forceFlush?.(), deadline);
768
+ await runShutdownStep("Langfuse score flush", () => rt.scoreClient.flush?.(), deadline);
769
+ await runShutdownStep("Langfuse client shutdown", () => rt.scoreClient.shutdown?.(), deadline);
770
+ await runShutdownStep("OTel tracer shutdown", () => rt.tracerProvider?.shutdown?.(), deadline);
771
+ await runShutdownStep(
673
772
  "REST fallback ingestion",
674
773
  () => fallbackToRestIngestion(rt, controller.signal),
675
774
  deadline,
775
+ controller.signal,
676
776
  );
677
- await withShutdownDeadline("Langfuse score flush", () => rt.scoreClient.flush?.(), deadline);
678
- await withShutdownDeadline("Langfuse client shutdown", () => rt.scoreClient.shutdown?.(), deadline);
679
- await withShutdownDeadline("OTel tracer shutdown", () => rt.tracerProvider?.shutdown?.(), deadline);
680
- } catch (e) {
681
- rememberRuntimeError("runtime shutdown", e);
682
- console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
683
777
  } finally {
684
778
  clearTimeout(abortTimeout);
685
779
  clearTimeout(scoreAbortTimeout);