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 +1 -1
- package/README_CN.md +1 -1
- package/index.ts +4 -1
- package/package.json +1 -1
- package/src/handlers/agent.ts +44 -14
- package/src/langfuse.ts +117 -23
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
|
|
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
|
-
-
|
|
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
package/src/handlers/agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
535
|
+
return "unknown";
|
|
458
536
|
}
|
|
459
|
-
return
|
|
537
|
+
return "visible";
|
|
460
538
|
} catch (error) {
|
|
461
539
|
if (signal.aborted) {
|
|
462
540
|
throw error;
|
|
463
541
|
}
|
|
464
|
-
return
|
|
542
|
+
return "unknown";
|
|
465
543
|
}
|
|
466
544
|
}
|
|
467
545
|
|
|
468
|
-
async function waitForTraceVisibility(
|
|
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
|
-
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
750
|
+
scoreShutdownTimeoutMs,
|
|
660
751
|
);
|
|
661
752
|
scoreAbortTimeout.unref?.();
|
|
662
753
|
stopScoreFlush(rt);
|
|
663
754
|
|
|
664
755
|
try {
|
|
665
|
-
await
|
|
756
|
+
await runShutdownStep(
|
|
666
757
|
"Active score flush",
|
|
667
758
|
() => rt.scoreFlushPromise,
|
|
668
759
|
deadline,
|
|
669
760
|
);
|
|
670
|
-
await
|
|
671
|
-
|
|
672
|
-
|
|
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);
|