pi-langfuse 1.3.2 → 1.4.0
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 +7 -7
- package/README_CN.md +7 -7
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/config.ts +10 -5
- package/src/constants.ts +4 -7
- package/src/langfuse.ts +282 -2
- package/src/types.ts +9 -0
- package/types/node-shims.d.ts +9 -0
- package/.trae/documents/optimize_langfuse_reporting.md +0 -68
- package/.trae/documents/pi-langfuse-refactor.md +0 -78
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Langfuse provides open-source observability for LLM applications. This extension
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
16
|
- **Complete Agent Traces**: Creates one trace per user prompt with a root `agent` observation containing the prompt input and final assistant output.
|
|
17
|
+
- **REST fallback for self-hosted Langfuse**: Uses the Langfuse OpenTelemetry SDK first, then verifies that the trace is visible. If a self-hosted OTel ingestion pipeline accepts spans but does not materialize traces, the extension writes the run through Langfuse's REST ingestion API.
|
|
17
18
|
- **Per-Request Generations**: Records a separate `generation` observation for every provider request, including the actual provider payload instead of only the original prompt.
|
|
18
19
|
- **Final Message Capture**: Uses finalized assistant messages for generation and root outputs, so Langfuse shows what the user actually saw in Pi.
|
|
19
20
|
- **Tool Observability**: Creates Langfuse `tool` observations for every tool call, including arguments, results, and error states.
|
|
@@ -78,7 +79,7 @@ Run any `pi` command with the extension loaded. On first run without configurati
|
|
|
78
79
|
2. **Langfuse secret key** — starts with `sk-lf-...`
|
|
79
80
|
3. **Langfuse host** — defaults to `https://cloud.langfuse.com`
|
|
80
81
|
|
|
81
|
-
The extension saves these to
|
|
82
|
+
The extension saves these to `~/.pi/agent/pi-langfuse/config.json`, so package updates and reinstalls do not overwrite your Langfuse credentials.
|
|
82
83
|
|
|
83
84
|
To re-run setup at any time:
|
|
84
85
|
|
|
@@ -86,7 +87,7 @@ To re-run setup at any time:
|
|
|
86
87
|
/langfuse-setup
|
|
87
88
|
```
|
|
88
89
|
|
|
89
|
-
### Method 2: Environment variables
|
|
90
|
+
### Method 2: Environment variables (fallback)
|
|
90
91
|
|
|
91
92
|
Set these before starting Pi:
|
|
92
93
|
|
|
@@ -96,11 +97,11 @@ export LANGFUSE_SECRET_KEY="sk-lf-xxxx"
|
|
|
96
97
|
export LANGFUSE_BASE_URL="https://cloud.langfuse.com" # optional; LANGFUSE_HOST is also supported
|
|
97
98
|
```
|
|
98
99
|
|
|
99
|
-
The
|
|
100
|
+
The saved config file takes precedence. Environment variables are used only when `~/.pi/agent/pi-langfuse/config.json` does not exist or is incomplete, which avoids drift after re-running `/langfuse-setup`.
|
|
100
101
|
|
|
101
|
-
### Method 3:
|
|
102
|
+
### Method 3: Persistent config.json
|
|
102
103
|
|
|
103
|
-
For local
|
|
104
|
+
For persistent local configuration, create or update `~/.pi/agent/pi-langfuse/config.json`:
|
|
104
105
|
|
|
105
106
|
```json
|
|
106
107
|
{
|
|
@@ -110,7 +111,7 @@ For local development, create a `config.json` in the project root:
|
|
|
110
111
|
}
|
|
111
112
|
```
|
|
112
113
|
|
|
113
|
-
> **⚠️ Security**:
|
|
114
|
+
> **⚠️ Security**: Keep `~/.pi/agent/pi-langfuse/config.json` private. Never commit API keys to version control.
|
|
114
115
|
|
|
115
116
|
## Usage
|
|
116
117
|
|
|
@@ -160,7 +161,6 @@ pi-langfuse/
|
|
|
160
161
|
├── index.ts # Extension entrypoint and core logic
|
|
161
162
|
├── package.json # Package metadata
|
|
162
163
|
├── tsconfig.json # TypeScript configuration
|
|
163
|
-
├── config.json # Local credentials (git-ignored)
|
|
164
164
|
├── types/
|
|
165
165
|
│ ├── pi-coding-agent.d.ts # Pi extension API types
|
|
166
166
|
│ └── node-shims.d.ts # Node.js module shims
|
package/README_CN.md
CHANGED
|
@@ -14,6 +14,7 @@ Langfuse 为 LLM 应用程序提供开源的可观测性。此扩展允许您以
|
|
|
14
14
|
## 功能
|
|
15
15
|
|
|
16
16
|
- **完整的代理追踪**:为每个用户提示词创建一个追踪,包含一个根 `agent`(代理)观察节点,其中记录了提示词输入和最终助手输出。
|
|
17
|
+
- **自建 Langfuse 的 REST 兜底**:优先使用 Langfuse OpenTelemetry SDK 上报,然后验证追踪是否可见。如果自建 OTel 摄取链路接受了 span 但没有生成 trace,扩展会通过 Langfuse REST ingestion API 补写本次运行。
|
|
17
18
|
- **每次请求生成记录**:为每次提供商请求记录单独的 `generation`(生成)观察节点,包含实际的提供商请求负载,而不仅仅是原始提示词。
|
|
18
19
|
- **捕获最终消息**:在生成和根输出中使用已定型的助手消息,因此 Langfuse 会显示用户在 Pi 中实际看到的内容。
|
|
19
20
|
- **工具可观测性**:为每次工具调用创建 Langfuse `tool`(工具)观察节点,包括参数、结果和错误状态。
|
|
@@ -78,7 +79,7 @@ pi link /path/to/pi-langfuse
|
|
|
78
79
|
2. **Langfuse 密钥** — 以 `sk-lf-...` 开头
|
|
79
80
|
3. **Langfuse 主机地址** — 默认为 `https://cloud.langfuse.com`
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
扩展会将这些保存到 `~/.pi/agent/pi-langfuse/config.json`,这样 Pi 更新、重装扩展时不会覆盖你的 Langfuse 凭据。
|
|
82
83
|
|
|
83
84
|
随时重新运行设置:
|
|
84
85
|
|
|
@@ -86,7 +87,7 @@ pi link /path/to/pi-langfuse
|
|
|
86
87
|
/langfuse-setup
|
|
87
88
|
```
|
|
88
89
|
|
|
89
|
-
### 方式 2
|
|
90
|
+
### 方式 2:环境变量(兜底)
|
|
90
91
|
|
|
91
92
|
在启动 Pi 前设置:
|
|
92
93
|
|
|
@@ -96,11 +97,11 @@ export LANGFUSE_SECRET_KEY="sk-lf-xxxx"
|
|
|
96
97
|
export LANGFUSE_BASE_URL="https://cloud.langfuse.com" # 可选;也支持 LANGFUSE_HOST
|
|
97
98
|
```
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
保存的配置文件优先级更高。只有当 `~/.pi/agent/pi-langfuse/config.json` 不存在或不完整时,扩展才会使用环境变量,这样重新运行 `/langfuse-setup` 后不会出现配置漂移。
|
|
100
101
|
|
|
101
|
-
### 方式 3
|
|
102
|
+
### 方式 3:持久化 config.json
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
如需使用持久化本地配置,创建或更新 `~/.pi/agent/pi-langfuse/config.json`:
|
|
104
105
|
|
|
105
106
|
```json
|
|
106
107
|
{
|
|
@@ -110,7 +111,7 @@ export LANGFUSE_BASE_URL="https://cloud.langfuse.com" # 可选;也支持 LANG
|
|
|
110
111
|
}
|
|
111
112
|
```
|
|
112
113
|
|
|
113
|
-
> **⚠️
|
|
114
|
+
> **⚠️ 安全提醒**:请保护好 `~/.pi/agent/pi-langfuse/config.json`。切勿将 API 密钥提交到版本控制。
|
|
114
115
|
|
|
115
116
|
## 使用
|
|
116
117
|
|
|
@@ -160,7 +161,6 @@ pi-langfuse/
|
|
|
160
161
|
├── index.ts # 扩展入口和核心逻辑
|
|
161
162
|
├── package.json # 包元数据
|
|
162
163
|
├── tsconfig.json # TypeScript 配置
|
|
163
|
-
├── config.json # 本地凭据(git 忽略)
|
|
164
164
|
├── types/
|
|
165
165
|
│ ├── pi-coding-agent.d.ts # Pi 扩展 API 类型
|
|
166
166
|
│ └── node-shims.d.ts # Node.js 模块 shims
|
package/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { basename } from "node:path";
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
13
|
import { state, resetRunState } from "./src/state.js";
|
|
14
|
-
import { ensureConfig, promptForConfig,
|
|
14
|
+
import { ensureConfig, promptForConfig, loadConfig } from "./src/config.js";
|
|
15
15
|
import { shutdownRuntime } from "./src/langfuse.js";
|
|
16
16
|
import { getMessageFromEvent, extractAssistantOutput } from "./src/utils.js";
|
|
17
17
|
import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export default async function (pi: ExtensionAPI) {
|
|
37
37
|
if (!state.config) {
|
|
38
|
-
state.config =
|
|
38
|
+
state.config = loadConfig();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
if (state.config) {
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
2
2
|
import type { Config } from "./types.js";
|
|
3
|
-
import { CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
|
|
3
|
+
import { CONFIG_DIR, CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
|
|
4
4
|
import { state } from "./state.js";
|
|
5
5
|
import { shutdownRuntime } from "./langfuse.js";
|
|
6
6
|
|
|
@@ -38,7 +38,12 @@ export function loadConfigFromEnv(): Config | null {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function loadConfig(): Config | null {
|
|
42
|
+
return loadConfigFromFile() || loadConfigFromEnv();
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
export function saveConfig(config: Config) {
|
|
46
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
42
47
|
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -79,7 +84,7 @@ async function saveConfigFromUI(ctx: any, config: Config): Promise<boolean> {
|
|
|
79
84
|
return true;
|
|
80
85
|
} catch (error) {
|
|
81
86
|
console.warn("📊 Langfuse: Failed to save config.json", error);
|
|
82
|
-
ctx.ui.notify(
|
|
87
|
+
ctx.ui.notify(`Failed to save Langfuse config.json to ${CONFIG_PATH}. Check Pi config directory permissions.`, "error");
|
|
83
88
|
state.config = null;
|
|
84
89
|
return false;
|
|
85
90
|
}
|
|
@@ -87,7 +92,7 @@ async function saveConfigFromUI(ctx: any, config: Config): Promise<boolean> {
|
|
|
87
92
|
|
|
88
93
|
export async function ensureConfig(ctx: any): Promise<boolean> {
|
|
89
94
|
if (!state.config) {
|
|
90
|
-
state.config =
|
|
95
|
+
state.config = loadConfig();
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
if (state.config) {
|
|
@@ -114,7 +119,7 @@ export async function promptForConfig(ctx: any): Promise<boolean> {
|
|
|
114
119
|
|
|
115
120
|
const config = await collectConfigFromUI(ctx, "Manual setup requested");
|
|
116
121
|
if (!config) {
|
|
117
|
-
state.config =
|
|
122
|
+
state.config = loadConfig();
|
|
118
123
|
return false;
|
|
119
124
|
}
|
|
120
125
|
|
package/src/constants.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// `dirname` gives us `src/`, and `resolve(..., '..')` gives us the root.
|
|
7
|
-
export const EXT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
-
export const CONFIG_PATH = resolve(EXT_DIR, "config.json");
|
|
4
|
+
export const CONFIG_DIR = resolve(homedir(), ".pi", "agent", "pi-langfuse");
|
|
5
|
+
export const CONFIG_PATH = resolve(CONFIG_DIR, "config.json");
|
|
9
6
|
export const DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com";
|
|
10
7
|
|
|
11
8
|
export const MAX_STRING_LENGTH = 12_000;
|
package/src/langfuse.ts
CHANGED
|
@@ -1,8 +1,277 @@
|
|
|
1
1
|
import type { LangfuseRuntime, LangfuseScoreClient } from "./types.js";
|
|
2
2
|
import { state } from "./state.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
let runtime: LangfuseRuntime | null = null;
|
|
5
6
|
|
|
7
|
+
type FallbackObservationType = "SPAN" | "GENERATION";
|
|
8
|
+
|
|
9
|
+
interface RestFallbackTrace {
|
|
10
|
+
id: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
name: string;
|
|
13
|
+
input?: unknown;
|
|
14
|
+
output?: unknown;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RestFallbackObservation {
|
|
20
|
+
id: string;
|
|
21
|
+
traceId: string;
|
|
22
|
+
type: FallbackObservationType;
|
|
23
|
+
name: string;
|
|
24
|
+
startTime: string;
|
|
25
|
+
endTime?: string;
|
|
26
|
+
parentObservationId?: string;
|
|
27
|
+
input?: unknown;
|
|
28
|
+
output?: unknown;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
model?: string;
|
|
31
|
+
usageDetails?: Record<string, number>;
|
|
32
|
+
costDetails?: Record<string, number>;
|
|
33
|
+
level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
|
|
34
|
+
statusMessage?: string;
|
|
35
|
+
completionStartTime?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RestFallbackStore {
|
|
39
|
+
trace?: RestFallbackTrace;
|
|
40
|
+
observations: RestFallbackObservation[];
|
|
41
|
+
observationById: Map<string, RestFallbackObservation>;
|
|
42
|
+
attempted: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const OTEL_VISIBILITY_DELAY_MS = 1_500;
|
|
46
|
+
|
|
47
|
+
function nowIso() {
|
|
48
|
+
return new Date().toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function delay(ms: number) {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toIso(value: unknown): string | undefined {
|
|
56
|
+
if (!value) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (value instanceof Date) {
|
|
60
|
+
return value.toISOString();
|
|
61
|
+
}
|
|
62
|
+
if (typeof value === "string") {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mergeMetadata(current: Record<string, unknown> | undefined, next: Record<string, unknown> | undefined) {
|
|
69
|
+
return next ? { ...(current ?? {}), ...next } : current;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyObservationUpdate(record: RestFallbackObservation, body: Record<string, unknown> | undefined) {
|
|
73
|
+
if (!body) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if ("input" in body) record.input = body.input;
|
|
78
|
+
if ("output" in body) record.output = body.output;
|
|
79
|
+
if ("metadata" in body && body.metadata && typeof body.metadata === "object") {
|
|
80
|
+
record.metadata = mergeMetadata(record.metadata, body.metadata as Record<string, unknown>);
|
|
81
|
+
}
|
|
82
|
+
if (typeof body.model === "string") record.model = body.model;
|
|
83
|
+
if (body.usageDetails && typeof body.usageDetails === "object") {
|
|
84
|
+
record.usageDetails = body.usageDetails as Record<string, number>;
|
|
85
|
+
}
|
|
86
|
+
if (body.costDetails && typeof body.costDetails === "object") {
|
|
87
|
+
record.costDetails = body.costDetails as Record<string, number>;
|
|
88
|
+
}
|
|
89
|
+
if (typeof body.level === "string") record.level = body.level as RestFallbackObservation["level"];
|
|
90
|
+
if (typeof body.statusMessage === "string") record.statusMessage = body.statusMessage;
|
|
91
|
+
const completionStartTime = toIso(body.completionStartTime);
|
|
92
|
+
if (completionStartTime) record.completionStartTime = completionStartTime;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applyTraceUpdate(store: RestFallbackStore, body: Record<string, unknown> | undefined) {
|
|
96
|
+
if (!store.trace || !body) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ("input" in body) store.trace.input = body.input;
|
|
101
|
+
if ("output" in body) store.trace.output = body.output;
|
|
102
|
+
if ("metadata" in body && body.metadata && typeof body.metadata === "object") {
|
|
103
|
+
store.trace.metadata = mergeMetadata(store.trace.metadata, body.metadata as Record<string, unknown>);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function observationType(asType?: string): FallbackObservationType {
|
|
108
|
+
return asType === "generation" ? "GENERATION" : "SPAN";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function wrapObservation(
|
|
112
|
+
observation: any,
|
|
113
|
+
store: RestFallbackStore,
|
|
114
|
+
name: string,
|
|
115
|
+
body: Record<string, unknown> | undefined,
|
|
116
|
+
asType?: string,
|
|
117
|
+
parentObservationId?: string,
|
|
118
|
+
): any {
|
|
119
|
+
const id = observation.id || randomUUID();
|
|
120
|
+
const traceId = observation.traceId || store.trace?.id || randomUUID();
|
|
121
|
+
const metadata = body?.metadata && typeof body.metadata === "object" ? body.metadata as Record<string, unknown> : undefined;
|
|
122
|
+
const record: RestFallbackObservation = {
|
|
123
|
+
id,
|
|
124
|
+
traceId,
|
|
125
|
+
name,
|
|
126
|
+
type: observationType(asType),
|
|
127
|
+
startTime: nowIso(),
|
|
128
|
+
parentObservationId,
|
|
129
|
+
metadata: mergeMetadata(metadata, asType && asType !== "generation" && asType !== "span" ? { langfuseObservationType: asType } : undefined),
|
|
130
|
+
};
|
|
131
|
+
applyObservationUpdate(record, body);
|
|
132
|
+
|
|
133
|
+
store.observations.push(record);
|
|
134
|
+
store.observationById.set(id, record);
|
|
135
|
+
|
|
136
|
+
if (!parentObservationId && !store.trace) {
|
|
137
|
+
store.trace = {
|
|
138
|
+
id: traceId,
|
|
139
|
+
timestamp: record.startTime,
|
|
140
|
+
name,
|
|
141
|
+
input: body?.input,
|
|
142
|
+
sessionId: typeof metadata?.sessionId === "string" ? metadata.sessionId : state.currentSessionId || undefined,
|
|
143
|
+
metadata,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...observation,
|
|
149
|
+
id,
|
|
150
|
+
traceId,
|
|
151
|
+
update(updateBody?: Record<string, unknown>) {
|
|
152
|
+
applyObservationUpdate(record, updateBody);
|
|
153
|
+
if (!parentObservationId) {
|
|
154
|
+
applyTraceUpdate(store, updateBody);
|
|
155
|
+
}
|
|
156
|
+
const updated = observation.update(updateBody);
|
|
157
|
+
return updated === observation ? this : updated;
|
|
158
|
+
},
|
|
159
|
+
end(endBody?: Record<string, unknown>) {
|
|
160
|
+
if (endBody && typeof endBody === "object") {
|
|
161
|
+
applyObservationUpdate(record, endBody);
|
|
162
|
+
if (!parentObservationId) {
|
|
163
|
+
applyTraceUpdate(store, endBody);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
record.endTime = nowIso();
|
|
167
|
+
return observation.end();
|
|
168
|
+
},
|
|
169
|
+
startObservation(childName: string, childBody?: Record<string, unknown>, options?: { asType?: string }) {
|
|
170
|
+
const child = observation.startObservation(childName, childBody, options);
|
|
171
|
+
return wrapObservation(child, store, childName, childBody, options?.asType, id);
|
|
172
|
+
},
|
|
173
|
+
setTraceIO(traceBody?: { input?: unknown; output?: unknown }) {
|
|
174
|
+
applyTraceUpdate(store, traceBody);
|
|
175
|
+
return observation.setTraceIO?.(traceBody);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function traceExists(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
|
|
181
|
+
try {
|
|
182
|
+
const getTrace = rt.scoreClient.api?.trace?.get;
|
|
183
|
+
if (!getTrace) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
await getTrace(traceId);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function eventTimestamp(record: { endTime?: string; startTime?: string; timestamp?: string }) {
|
|
194
|
+
return record.endTime ?? record.startTime ?? record.timestamp ?? nowIso();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fallbackToRestIngestion(rt: LangfuseRuntime) {
|
|
198
|
+
const store = rt.restFallback as RestFallbackStore | undefined;
|
|
199
|
+
if (!store?.trace || store.attempted) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
store.attempted = true;
|
|
203
|
+
|
|
204
|
+
await delay(OTEL_VISIBILITY_DELAY_MS);
|
|
205
|
+
if (await traceExists(rt, store.trace.id)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const trace = store.trace;
|
|
210
|
+
const batch: any[] = [
|
|
211
|
+
{
|
|
212
|
+
type: "trace-create",
|
|
213
|
+
id: randomUUID(),
|
|
214
|
+
timestamp: eventTimestamp(trace),
|
|
215
|
+
body: {
|
|
216
|
+
id: trace.id,
|
|
217
|
+
timestamp: trace.timestamp,
|
|
218
|
+
name: trace.name,
|
|
219
|
+
input: trace.input,
|
|
220
|
+
output: trace.output,
|
|
221
|
+
sessionId: trace.sessionId,
|
|
222
|
+
metadata: trace.metadata,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const observation of store.observations) {
|
|
228
|
+
const body = {
|
|
229
|
+
id: observation.id,
|
|
230
|
+
traceId: observation.traceId,
|
|
231
|
+
name: observation.name,
|
|
232
|
+
startTime: observation.startTime,
|
|
233
|
+
endTime: observation.endTime,
|
|
234
|
+
input: observation.input,
|
|
235
|
+
output: observation.output,
|
|
236
|
+
metadata: observation.metadata,
|
|
237
|
+
level: observation.level,
|
|
238
|
+
statusMessage: observation.statusMessage,
|
|
239
|
+
parentObservationId: observation.parentObservationId,
|
|
240
|
+
...(observation.type === "GENERATION"
|
|
241
|
+
? {
|
|
242
|
+
completionStartTime: observation.completionStartTime,
|
|
243
|
+
model: observation.model,
|
|
244
|
+
usageDetails: observation.usageDetails,
|
|
245
|
+
costDetails: observation.costDetails,
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
};
|
|
249
|
+
batch.push({
|
|
250
|
+
type: observation.type === "GENERATION" ? "generation-create" : "span-create",
|
|
251
|
+
id: randomUUID(),
|
|
252
|
+
timestamp: eventTimestamp(observation),
|
|
253
|
+
body,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const response = await rt.scoreClient.api?.ingestion?.batch?.({
|
|
258
|
+
batch,
|
|
259
|
+
metadata: {
|
|
260
|
+
source: "pi-langfuse",
|
|
261
|
+
fallback: "rest-ingestion",
|
|
262
|
+
reason: "otel-trace-not-visible-after-flush",
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const responseBody = response as { errors?: unknown[] } | undefined;
|
|
267
|
+
const errors = Array.isArray(responseBody?.errors) ? responseBody.errors : [];
|
|
268
|
+
if (errors.length > 0) {
|
|
269
|
+
console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
|
|
270
|
+
} else {
|
|
271
|
+
console.warn(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
6
275
|
export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
7
276
|
if (!state.config) {
|
|
8
277
|
throw new Error("Langfuse config is not set");
|
|
@@ -16,6 +285,12 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
16
285
|
import("@langfuse/client"),
|
|
17
286
|
]);
|
|
18
287
|
|
|
288
|
+
const restFallback: RestFallbackStore = {
|
|
289
|
+
observations: [],
|
|
290
|
+
observationById: new Map(),
|
|
291
|
+
attempted: false,
|
|
292
|
+
};
|
|
293
|
+
|
|
19
294
|
const spanProcessor = new LangfuseSpanProcessor({
|
|
20
295
|
publicKey: state.config.publicKey,
|
|
21
296
|
secretKey: state.config.secretKey,
|
|
@@ -25,7 +300,10 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
25
300
|
tracing.setLangfuseTracerProvider(tracerProvider);
|
|
26
301
|
|
|
27
302
|
runtime = {
|
|
28
|
-
startObservation:
|
|
303
|
+
startObservation: ((name: string, body?: Record<string, unknown>, options?: { asType?: string }) => {
|
|
304
|
+
const observation = (tracing as any).startObservation(name, body, options);
|
|
305
|
+
return wrapObservation(observation, restFallback, name, body, options?.asType);
|
|
306
|
+
}) as unknown as LangfuseRuntime["startObservation"],
|
|
29
307
|
propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
|
|
30
308
|
scoreClient: new LangfuseClient({
|
|
31
309
|
publicKey: state.config.publicKey,
|
|
@@ -35,6 +313,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
35
313
|
spanProcessor,
|
|
36
314
|
tracerProvider,
|
|
37
315
|
clearTracerProvider: () => tracing.setLangfuseTracerProvider(null),
|
|
316
|
+
restFallback,
|
|
38
317
|
};
|
|
39
318
|
}
|
|
40
319
|
|
|
@@ -47,9 +326,10 @@ export async function shutdownRuntime(): Promise<void> {
|
|
|
47
326
|
}
|
|
48
327
|
|
|
49
328
|
try {
|
|
329
|
+
await runtime.tracerProvider?.forceFlush?.();
|
|
330
|
+
await fallbackToRestIngestion(runtime);
|
|
50
331
|
await runtime.scoreClient.flush?.();
|
|
51
332
|
await runtime.scoreClient.shutdown?.();
|
|
52
|
-
await runtime.tracerProvider?.forceFlush?.();
|
|
53
333
|
await runtime.tracerProvider?.shutdown?.();
|
|
54
334
|
} catch (e) {
|
|
55
335
|
console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface ObservationUpdate {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export interface LangfuseScoreClient {
|
|
34
|
+
api?: {
|
|
35
|
+
trace?: {
|
|
36
|
+
get?: (traceId: string) => Promise<unknown>;
|
|
37
|
+
};
|
|
38
|
+
ingestion?: {
|
|
39
|
+
batch?: (request: unknown) => Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
34
42
|
score?: {
|
|
35
43
|
create(body: {
|
|
36
44
|
traceId?: string;
|
|
@@ -64,6 +72,7 @@ export interface LangfuseRuntime {
|
|
|
64
72
|
spanProcessor?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
|
|
65
73
|
tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
|
|
66
74
|
clearTracerProvider?: () => void;
|
|
75
|
+
restFallback?: unknown;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export interface GenerationState {
|
package/types/node-shims.d.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
declare module "node:fs" {
|
|
2
|
+
export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
|
|
2
3
|
export function readFileSync(path: string, encoding: string): string;
|
|
3
4
|
export function existsSync(path: string): boolean;
|
|
4
5
|
export function writeFileSync(path: string, data: string, encoding: string): void;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
declare module "node:crypto" {
|
|
9
|
+
export function randomUUID(): string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module "node:os" {
|
|
13
|
+
export function homedir(): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
declare module "node:path" {
|
|
8
17
|
export function resolve(...paths: string[]): string;
|
|
9
18
|
export function dirname(path: string): string;
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# Langfuse 追踪逻辑优化方案
|
|
2
|
-
|
|
3
|
-
## 1. 背景与目标
|
|
4
|
-
根据之前的建议,我们需要进一步优化 Pi-Langfuse 扩展的上报逻辑,使得在 Langfuse 控制台中观察 Agent 执行过程时更加细致、层级分明且数据完备。
|
|
5
|
-
本次优化包含 5 个核心点:引入 Turn 级别的 Span、精准追踪 TTFT(首字响应时间)、处理 Provider 请求异常、上报 System Prompt,以及完善 Session 的边缘生命周期和上下文压缩(Compact)处理。
|
|
6
|
-
|
|
7
|
-
## 2. 现状分析
|
|
8
|
-
- **层级结构**:目前 `llm-generation` 和 `tool` 是平级挂载在根 Trace 下,多轮对话时瀑布图非常扁平。
|
|
9
|
-
- **TTFT**:尚未记录 `completionStartTime`,无法在 Langfuse 中直观看到首字耗时。
|
|
10
|
-
- **异常闭环**:LLM Provider 发生 4xx/5xx HTTP 错误时,`message_end` 可能不触发,导致 Generation 无法正常闭合。
|
|
11
|
-
- **上下文完备性**:`before_agent_start` 仅提取了 user prompt,缺少 System Prompt,不利于后期 Debug。
|
|
12
|
-
- **Session 生命周期**:仅处理了 `session_start` 和 `session_shutdown`,忽略了 `/new`, `/resume`, `/fork`, `/compact` 等高级会话操作引发的状态混乱问题。
|
|
13
|
-
|
|
14
|
-
## 3. 具体修改方案
|
|
15
|
-
|
|
16
|
-
### 3.1 引入 Turn-Level Span (层级结构优化)
|
|
17
|
-
将一轮对话(Turn)包装为一个 Span,使其成为 Generation 和 Tool 的父节点。
|
|
18
|
-
- **`src/types.ts`**:
|
|
19
|
-
- 在 `AgentState` 接口中增加 `activeTurn?: LangfuseObservation` 字段。
|
|
20
|
-
- **`src/handlers/turn.ts` (新建)**:
|
|
21
|
-
- 实现 `startTurnObservation(event)`:调用 `state.agentState.root.startObservation("turn", {...}, { asType: "span" })` 并赋值给 `activeTurn`。
|
|
22
|
-
- 实现 `finishTurnObservation(event)`:结束 `activeTurn` 并将其置空。
|
|
23
|
-
- **`src/handlers/generation.ts` & `src/handlers/tool.ts`**:
|
|
24
|
-
- 在创建 `llm-generation` 和 `tool` 观察节点时,优先判断 `state.agentState.activeTurn` 是否存在,若存在则调用 `activeTurn.startObservation`,否则降级使用 `root.startObservation`。
|
|
25
|
-
- **`index.ts`**:
|
|
26
|
-
- 注册 `turn_start` 事件,调用 `startTurnObservation`。
|
|
27
|
-
- 在现有的 `turn_end` 监听器中,除了处理 Fallback Generation,还需要调用 `finishTurnObservation`。
|
|
28
|
-
|
|
29
|
-
### 3.2 精准追踪 TTFT (首字生成时间)
|
|
30
|
-
记录模型流式输出第一块 Chunk 的时间,用于计算 TTFT。
|
|
31
|
-
- **`src/types.ts`**:
|
|
32
|
-
- 在 `GenerationState` 中增加 `ttftRecorded?: boolean` 标志位。
|
|
33
|
-
- **`src/handlers/generation.ts`**:
|
|
34
|
-
- 新增 `recordTTFT(event)` 函数:获取当前 `activeGeneration`,若 `ttftRecorded` 为 false/undefined,则调用 `generation.observation.update({ completionStartTime: new Date() })`,并标记 `ttftRecorded = true`。
|
|
35
|
-
- **`index.ts`**:
|
|
36
|
-
- 在 `message_update` 事件监听器中,除了原有的提取逻辑外,新增调用 `recordTTFT(event)`。
|
|
37
|
-
|
|
38
|
-
### 3.3 异常流闭环:Provider 请求级错误处理
|
|
39
|
-
防止因 Provider HTTP 请求失败导致 Generation 永远处于开启状态。
|
|
40
|
-
- **`src/handlers/generation.ts`**:
|
|
41
|
-
- 修改 `updateGenerationMetadata(event)` 函数。在提取完 metadata 后,检查 `metadata.status`。
|
|
42
|
-
- 如果 `status >= 400` 或者存在明确的 error 信息,立刻将该 Generation 的状态设为 `level: "ERROR"`,附加 `statusMessage`,并调用 `.end()` 提前闭合它,同时设置 `ended = true`。
|
|
43
|
-
|
|
44
|
-
### 3.4 完善上下文信息:上报 System Prompt
|
|
45
|
-
将 System Prompt 纳入 Trace 的元数据或输入中,方便回溯。
|
|
46
|
-
- **`src/handlers/agent.ts`**:
|
|
47
|
-
- 修改 `startAgentRun(event, ctx)`。
|
|
48
|
-
- 增加 `const systemPrompt = await ctx.getSystemPrompt();`。
|
|
49
|
-
- 在 `root` Trace 的 `metadata` 中增加 `systemPrompt: truncate(systemPrompt, MAX_TOOL_PAYLOAD_LENGTH)`。
|
|
50
|
-
|
|
51
|
-
### 3.5 Session 边缘生命周期与 Compact 处理
|
|
52
|
-
完善 Pi 扩展 API 提供的各类会话切换与上下文管理事件。
|
|
53
|
-
- **`index.ts`**:
|
|
54
|
-
- 增加 `session_before_switch` 和 `session_before_fork` 的事件监听。当触发这些事件时,表明当前会话即将被替换,调用 `closeDanglingObservations("Session switched or forked")` 提前闭合所有挂起的节点,并调用 `resetRunState()`。
|
|
55
|
-
- 增加 `session_compact` 事件监听。如果当前 `state.agentState.root` 存在,调用 `root.startObservation("session_compact", { level: "DEFAULT", statusMessage: "Context was compacted" }, { asType: "span" }).end()`,在 Trace 中记录下压缩动作发生的时机。
|
|
56
|
-
|
|
57
|
-
## 4. 假设与决策
|
|
58
|
-
- **决策**: Turn Span 的命名直接使用 `"turn"`,类型使用 `span`,这样在 Langfuse 的甘特图中能清晰地包裹住内部的 Generation 和 Tool。
|
|
59
|
-
- **决策**: TTFT 使用系统当前时间 `new Date()`。尽管存在微小的事件传递延迟,但在 Node.js 环境下已足够精确。
|
|
60
|
-
- **假设**: `ctx.getSystemPrompt()` 是异步方法,在 `before_agent_start` 和 `agent_start` 中可以通过 `await` 正常获取。
|
|
61
|
-
- **决策**: 对于 Session Lifecycle 的处理,统一视作强行终止当前 Agent Run 的执行,因此复用 `closeDanglingObservations` 逻辑以保证不会出现悬空(Dangling)的 Trace/Span。
|
|
62
|
-
|
|
63
|
-
## 5. 验收标准
|
|
64
|
-
1. 在代码中实现上述 5 项修改,且不破坏现有 TypeScript 编译。
|
|
65
|
-
2. 运行 Pi Agent 进行一次多轮对话(调用工具),能够在 Langfuse 观察到 `Trace -> Turn -> Generation/Tool` 的层级结构。
|
|
66
|
-
3. Langfuse 的 Generation 详情中能够看到 `Time to First Token (TTFT)`。
|
|
67
|
-
4. Trace 的 Metadata 中包含 `systemPrompt` 字段。
|
|
68
|
-
5. 通过 `/new` 或 `/compact` 时,控制台不报挂起节点相关的警告。
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# Pi-Langfuse 代码重构与拆分计划
|
|
2
|
-
|
|
3
|
-
## 摘要 (Summary)
|
|
4
|
-
将当前长达 1105 行的单文件 `index.ts` 重构并按功能职责拆分至 `src/` 目录下的多个模块中。保留根目录的 `index.ts` 作为 Pi 扩展的纯入口文件。此举将极大提升代码的可读性和可维护性。
|
|
5
|
-
|
|
6
|
-
## 当前状态分析 (Current State Analysis)
|
|
7
|
-
目前 `index.ts` 是一个单文件巨石(Monolith),包含了以下所有逻辑:
|
|
8
|
-
1. 本地与环境变量配置读取
|
|
9
|
-
2. TypeScript 接口声明(`Config`, `AgentState`, `LangfuseObservation` 等)
|
|
10
|
-
3. 全局可变状态(如 `agentState`, `currentSessionId`, 各类计数器)
|
|
11
|
-
4. Langfuse SDK 的懒加载与生命周期管理
|
|
12
|
-
5. 数据格式化、截断与负载提取工具函数
|
|
13
|
-
6. Agent、Generation 和 Tool 各自的观测事件处理器
|
|
14
|
-
7. Pi 扩展命令注册与生命周期事件监听
|
|
15
|
-
|
|
16
|
-
所有逻辑混合在一起,导致状态变更难以追踪,且由于闭包共享了大量模块级状态,修改代码容易引入隐藏的副作用。
|
|
17
|
-
|
|
18
|
-
## 提议更改 (Proposed Changes)
|
|
19
|
-
|
|
20
|
-
### 1. 更新项目配置
|
|
21
|
-
- 修改 `tsconfig.json`:在 `include` 中增加 `"src/**/*.ts"`,以包含新的源码目录。
|
|
22
|
-
- **依赖引用规范**:由于 `tsconfig.json` 配置了 `"moduleResolution": "NodeNext"`,所有内部文件导入必须显式带有 `.js` 后缀(例如 `import { state } from "./state.js"`)。
|
|
23
|
-
|
|
24
|
-
### 2. 建立 `src/` 目录结构并拆分职责
|
|
25
|
-
计划新建如下文件与目录:
|
|
26
|
-
|
|
27
|
-
- **`src/types.ts`**
|
|
28
|
-
- **内容**:提取所有的 Interface 和 Type 定义。
|
|
29
|
-
- **包含**:`Config`, `LangfuseObservation`, `ObservationUpdate`, `LangfuseScoreClient`, `LangfuseRuntime`, `GenerationState`, `ToolState`, `AgentState`。
|
|
30
|
-
|
|
31
|
-
- **`src/constants.ts`**
|
|
32
|
-
- **内容**:提取所有魔法数字和常量配置。
|
|
33
|
-
- **包含**:`EXT_DIR`, `CONFIG_PATH`, `DEFAULT_LANGFUSE_HOST`, `MAX_STRING_LENGTH` 等。
|
|
34
|
-
|
|
35
|
-
- **`src/state.ts`**
|
|
36
|
-
- **内容**:统一管理所有全局可变状态。
|
|
37
|
-
- **实现方式**:将原来的 `let agentState`, `let currentSessionId` 等包裹在一个 `export const state = { ... }` 对象中,确保跨模块引用时状态一致。
|
|
38
|
-
- **包含函数**:`resetRunState`, `computeEvaluationScores`。
|
|
39
|
-
|
|
40
|
-
- **`src/utils.ts`**
|
|
41
|
-
- **内容**:集中存放与状态无关的纯工具函数。
|
|
42
|
-
- **包含**:`truncate`, `tryParseJson`, `shapePayload`, `safeSerialize`,以及一系列解析函数(`extractTextContent`, `extractToolCalls`, `getToolCallId` 等)。
|
|
43
|
-
|
|
44
|
-
- **`src/config.ts`**
|
|
45
|
-
- **内容**:负责配置读取与持久化,以及与 Pi UI 的交互提示。
|
|
46
|
-
- **包含**:`loadConfigFromFile`, `loadConfigFromEnv`, `saveConfig`, `ensureConfig`, `promptForConfig`。
|
|
47
|
-
|
|
48
|
-
- **`src/langfuse.ts`**
|
|
49
|
-
- **内容**:Langfuse SDK 客户端封装。
|
|
50
|
-
- **包含**:`getRuntime` (单例模式加载 SDK)、`shutdownRuntime`、`sendScore`。
|
|
51
|
-
|
|
52
|
-
- **`src/handlers/agent.ts`**
|
|
53
|
-
- **内容**:Agent 生命周期的事件逻辑。
|
|
54
|
-
- **包含**:`startAgentRun`, `finishAgentRun`, `updateTraceIO`。
|
|
55
|
-
|
|
56
|
-
- **`src/handlers/generation.ts`**
|
|
57
|
-
- **内容**:模型生成 (Generation) 相关的生命周期逻辑。
|
|
58
|
-
- **包含**:`getOpenGeneration`, `startGeneration`, `updateGenerationMetadata`, `finishGenerationFromMessage`, `createFallbackGenerationFromTurn`。
|
|
59
|
-
|
|
60
|
-
- **`src/handlers/tool.ts`**
|
|
61
|
-
- **内容**:工具调用 (Tool) 相关的逻辑。
|
|
62
|
-
- **包含**:`startToolObservation`, `finishToolObservation`, `closeDanglingObservations`。
|
|
63
|
-
|
|
64
|
-
### 3. 精简根目录 `index.ts`
|
|
65
|
-
- **内容**:将其转换为纯粹的事件路由中心。
|
|
66
|
-
- **改动**:删除原有的业务实现,改为从 `src/` 各个模块导入必要的 handler。保留默认导出的扩展注册函数 `export default async function (pi: ExtensionAPI)`,并在其中通过 `pi.on` 和 `pi.registerCommand` 将事件委派给对应的处理函数。
|
|
67
|
-
|
|
68
|
-
## 假设与决策 (Assumptions & Decisions)
|
|
69
|
-
- **零行为变更**:本次重构仅做结构上的梳理,不修改任何核心逻辑、状态变更时机或事件 payload 的生成规则,从而保证与当前 Langfuse 的数据对接一致。
|
|
70
|
-
- **状态管理**:采用共享的 `state` 单例对象替代原本的顶层变量,以最少的改动适配多文件架构。
|
|
71
|
-
- **兼容性**:保留根目录的 `index.ts` 以兼容现有的 `package.json` 中的 `pi.extensions` 配置,无需修改发布行为。
|
|
72
|
-
|
|
73
|
-
## 验证步骤 (Verification steps)
|
|
74
|
-
1. 运行 `npm run typecheck` 确认代码拆分后无 TypeScript 编译及引入报错。
|
|
75
|
-
2. 开启 Pi CLI (`pi "test prompt"`) 并挂载该本地扩展,验证:
|
|
76
|
-
- 配置初始化弹窗或环境变量读取是否正常工作。
|
|
77
|
-
- 工具调用、LLM 生成以及会话结束时的逻辑是否无异常抛出。
|
|
78
|
-
3. 登录 Langfuse 控制面板,核对新产生的 trace 数据结构(agent、generation、tool observations 及打分数据)是否完整,未出现状态泄漏或丢失。
|