pi-langfuse 1.5.13 → 1.5.15
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 +39 -29
- package/README_CN.md +72 -0
- package/package.json +1 -1
- package/src/capture-policy.ts +14 -2
- package/src/commands.ts +2 -1
- package/src/handlers/agent.ts +3 -2
- package/src/langfuse.ts +95 -5
- package/src/source-metadata.ts +53 -140
- package/types/langfuse-runtime-shims.d.ts +1 -1
package/README.md
CHANGED
|
@@ -108,8 +108,11 @@ export LANGFUSE_CAPTURE_OUTPUTS=true
|
|
|
108
108
|
export LANGFUSE_CAPTURE_TOOL_IO=false
|
|
109
109
|
export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
|
|
110
110
|
export LANGFUSE_CAPTURE_CWD=false
|
|
111
|
+
export LANGFUSE_CAPTURE_SOURCE_METADATA=false
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
Source metadata remains off in every preset unless `LANGFUSE_CAPTURE_SOURCE_METADATA=true` is set explicitly.
|
|
115
|
+
|
|
113
116
|
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.
|
|
114
117
|
|
|
115
118
|
### Payload limits
|
|
@@ -133,6 +136,19 @@ defaults shown above. To capture a very large system prompt or big tool
|
|
|
133
136
|
payloads in full, raise or disable the relevant limit (e.g.
|
|
134
137
|
`PI_LANGFUSE_MAX_STRING_LENGTH=off`).
|
|
135
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
|
+
|
|
136
152
|
### Method 3: Persistent `config.json`
|
|
137
153
|
|
|
138
154
|
Create or update `~/.pi/agent/pi-langfuse/config.json`:
|
|
@@ -198,50 +214,44 @@ The package also includes a Langfuse CLI skill, so Langfuse data can be queried
|
|
|
198
214
|
|
|
199
215
|
## Source Metadata
|
|
200
216
|
|
|
201
|
-
|
|
217
|
+
Repository source capture is independent of the privacy presets and disabled by default. Enable it only after deciding that commit identity is appropriate for the Langfuse project:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
export LANGFUSE_CAPTURE_SOURCE_METADATA=true
|
|
221
|
+
```
|
|
202
222
|
|
|
203
|
-
For Git
|
|
223
|
+
For a Git worktree, the extension records only revision state:
|
|
204
224
|
|
|
205
225
|
```json
|
|
206
226
|
{
|
|
207
227
|
"source_type": "git-repo",
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"repo_root_name": "repo",
|
|
212
|
-
"git_branch": "main",
|
|
213
|
-
"git_commit": "abc123",
|
|
214
|
-
"git_remote_host": "github.com",
|
|
215
|
-
"git_remote_path": "owner/repo",
|
|
228
|
+
"vcs.ref.head.revision": "0123456789abcdef...",
|
|
229
|
+
"git_detached": "false",
|
|
230
|
+
"git_dirty": "false",
|
|
216
231
|
"metadata_source": "git-detection"
|
|
217
232
|
}
|
|
218
233
|
```
|
|
219
234
|
|
|
220
|
-
|
|
235
|
+
The revision is the full `HEAD` commit. Dirty state includes tracked changes and untracked files, but never their paths or contents. Detached state is reported without a branch or tag name. Git remotes, URLs, credentials, usernames, branches, absolute paths, and repository names are never inspected or uploaded by this collector.
|
|
221
236
|
|
|
222
|
-
|
|
237
|
+
When capture is off, the collector does not invoke Git and reports `source_type: "disabled"`. Non-Git directories report `non-git`; a missing or unusable Git executable and incomplete Git state report `unavailable`.
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
environment
|
|
232
|
-
observability_owner
|
|
239
|
+
Use native Langfuse and OpenTelemetry settings for deployment identity and explicit operator-owned overrides instead of repository files:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
export LANGFUSE_RELEASE="1.2.3"
|
|
243
|
+
export LANGFUSE_TRACING_ENVIRONMENT="production"
|
|
244
|
+
export OTEL_SERVICE_NAME="pi-agent"
|
|
245
|
+
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.2.3,vcs.repository.name=public-repo"
|
|
233
246
|
```
|
|
234
247
|
|
|
235
|
-
|
|
248
|
+
`LANGFUSE_RELEASE` and `LANGFUSE_TRACING_ENVIRONMENT` keep their Langfuse semantics. `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` are loaded as process-scoped OpenTelemetry resource attributes; values such as `vcs.repository.name` apply to every session sharing the runtime, may identify private source, and require a runtime restart to change.
|
|
236
249
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"metadata_source": "non-git"
|
|
241
|
-
}
|
|
242
|
-
```
|
|
250
|
+
### Compatibility and rollback
|
|
251
|
+
|
|
252
|
+
Earlier versions emitted `git_commit`, branch, remote, owner, repository, and repo-local `.pi-langfuse.metadata.json` values by default. New traces use `vcs.ref.head.revision` and stop reading that file. Update dashboards before enabling source capture; historical traces are unchanged.
|
|
243
253
|
|
|
244
|
-
|
|
254
|
+
Unset `LANGFUSE_CAPTURE_SOURCE_METADATA` to stop collection immediately. Pin `pi-langfuse@1.5.12` only if the old schema is required during migration; doing so also restores its broader default source disclosure.
|
|
245
255
|
|
|
246
256
|
## Troubleshooting
|
|
247
257
|
|
package/README_CN.md
CHANGED
|
@@ -108,10 +108,41 @@ export LANGFUSE_CAPTURE_OUTPUTS=true
|
|
|
108
108
|
export LANGFUSE_CAPTURE_TOOL_IO=false
|
|
109
109
|
export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
|
|
110
110
|
export LANGFUSE_CAPTURE_CWD=false
|
|
111
|
+
export LANGFUSE_CAPTURE_SOURCE_METADATA=false
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
所有隐私预设默认都关闭源码元数据;只有显式设置 `LANGFUSE_CAPTURE_SOURCE_METADATA=true` 才会启用。
|
|
115
|
+
|
|
113
116
|
所有被采集的负载在上传前仍会脱敏。扩展会隐藏常见 API key、Bearer token、密码、Cookie、私钥、Langfuse key、GitHub/npm/AWS 风格 token,并对本地绝对路径做 hash。
|
|
114
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
|
+
|
|
115
146
|
### 方式 3:持久化 `config.json`
|
|
116
147
|
|
|
117
148
|
创建或更新 `~/.pi/agent/pi-langfuse/config.json`:
|
|
@@ -175,6 +206,47 @@ pi list
|
|
|
175
206
|
/pi-langfuse-langfuse <查询内容>
|
|
176
207
|
```
|
|
177
208
|
|
|
209
|
+
## 源码元数据
|
|
210
|
+
|
|
211
|
+
仓库源码采集独立于隐私预设,默认关闭。只有在确认提交标识适合写入当前 Langfuse 项目后才启用:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
export LANGFUSE_CAPTURE_SOURCE_METADATA=true
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
对于 Git 工作树,扩展只记录修订状态:
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"source_type": "git-repo",
|
|
222
|
+
"vcs.ref.head.revision": "0123456789abcdef...",
|
|
223
|
+
"git_detached": "false",
|
|
224
|
+
"git_dirty": "false",
|
|
225
|
+
"metadata_source": "git-detection"
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
修订值为完整的 `HEAD` 提交。dirty 状态包含已跟踪变更和未跟踪文件,但绝不包含路径或内容。detached 状态不会附带分支名或标签名。此采集器不会检查或上传 Git remote、URL、凭据、用户名、分支、绝对路径或仓库名。
|
|
230
|
+
|
|
231
|
+
关闭采集时,扩展不会调用 Git,并报告 `source_type: "disabled"`。非 Git 目录报告 `non-git`;Git 不可用或仓库状态不完整时报告 `unavailable`。
|
|
232
|
+
|
|
233
|
+
部署标识和显式覆盖应使用 Langfuse 与 OpenTelemetry 原生配置,而不是仓库文件:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
export LANGFUSE_RELEASE="1.2.3"
|
|
237
|
+
export LANGFUSE_TRACING_ENVIRONMENT="production"
|
|
238
|
+
export OTEL_SERVICE_NAME="pi-agent"
|
|
239
|
+
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.2.3,vcs.repository.name=public-repo"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`LANGFUSE_RELEASE` 与 `LANGFUSE_TRACING_ENVIRONMENT` 保持 Langfuse 原生语义。`OTEL_SERVICE_NAME` 和 `OTEL_RESOURCE_ATTRIBUTES` 会作为进程级 OpenTelemetry resource attributes 加载;`vcs.repository.name` 等值会应用到共享同一 runtime 的所有会话,可能暴露私有源码身份,而且修改后需要重启 runtime。
|
|
243
|
+
|
|
244
|
+
### 兼容与回滚
|
|
245
|
+
|
|
246
|
+
旧版本默认发送 `git_commit`、分支、remote、owner、仓库名及 `.pi-langfuse.metadata.json` 中的值。新 trace 改用 `vcs.ref.head.revision`,并停止读取该文件。启用源码采集前应更新 dashboard;历史 trace 不受影响。
|
|
247
|
+
|
|
248
|
+
取消设置 `LANGFUSE_CAPTURE_SOURCE_METADATA` 可立即停止采集。迁移期间只有在必须保留旧 schema 时才固定到 `pi-langfuse@1.5.12`;这样也会恢复旧版本更宽泛的默认源码披露。
|
|
249
|
+
|
|
178
250
|
## 故障排除
|
|
179
251
|
|
|
180
252
|
### 没有看到 trace
|
package/package.json
CHANGED
package/src/capture-policy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { redactValue } from "./redaction.js";
|
|
1
|
+
import { hashPath, redactValue } from "./redaction.js";
|
|
2
2
|
|
|
3
3
|
export interface CapturePolicy {
|
|
4
4
|
readonly captureInputs: boolean;
|
|
@@ -6,6 +6,7 @@ export interface CapturePolicy {
|
|
|
6
6
|
readonly captureToolIo: boolean;
|
|
7
7
|
readonly captureSystemPrompt: boolean;
|
|
8
8
|
readonly captureCwd: boolean;
|
|
9
|
+
readonly captureSourceMetadata: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export type PrivacyPreset = "metadata-only" | "prompts-only" | "conversations" | "full-debug";
|
|
@@ -36,6 +37,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
|
|
|
36
37
|
captureToolIo: false,
|
|
37
38
|
captureSystemPrompt: false,
|
|
38
39
|
captureCwd: false,
|
|
40
|
+
captureSourceMetadata: false,
|
|
39
41
|
},
|
|
40
42
|
"prompts-only": {
|
|
41
43
|
captureInputs: true,
|
|
@@ -43,6 +45,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
|
|
|
43
45
|
captureToolIo: false,
|
|
44
46
|
captureSystemPrompt: false,
|
|
45
47
|
captureCwd: false,
|
|
48
|
+
captureSourceMetadata: false,
|
|
46
49
|
},
|
|
47
50
|
conversations: {
|
|
48
51
|
captureInputs: true,
|
|
@@ -50,6 +53,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
|
|
|
50
53
|
captureToolIo: false,
|
|
51
54
|
captureSystemPrompt: false,
|
|
52
55
|
captureCwd: false,
|
|
56
|
+
captureSourceMetadata: false,
|
|
53
57
|
},
|
|
54
58
|
"full-debug": {
|
|
55
59
|
captureInputs: true,
|
|
@@ -57,6 +61,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
|
|
|
57
61
|
captureToolIo: true,
|
|
58
62
|
captureSystemPrompt: true,
|
|
59
63
|
captureCwd: true,
|
|
64
|
+
captureSourceMetadata: false,
|
|
60
65
|
},
|
|
61
66
|
};
|
|
62
67
|
|
|
@@ -66,6 +71,7 @@ const FLAG_TO_FIELD = {
|
|
|
66
71
|
LANGFUSE_CAPTURE_TOOL_IO: "captureToolIo",
|
|
67
72
|
LANGFUSE_CAPTURE_SYSTEM_PROMPT: "captureSystemPrompt",
|
|
68
73
|
LANGFUSE_CAPTURE_CWD: "captureCwd",
|
|
74
|
+
LANGFUSE_CAPTURE_SOURCE_METADATA: "captureSourceMetadata",
|
|
69
75
|
} as const;
|
|
70
76
|
|
|
71
77
|
function parseFlag(value: string | undefined): boolean | undefined {
|
|
@@ -105,7 +111,13 @@ function redactMetadata(metadata: Record<string, unknown> | undefined, policy: C
|
|
|
105
111
|
|
|
106
112
|
const output: Record<string, unknown> = {};
|
|
107
113
|
for (const [key, value] of Object.entries(metadata)) {
|
|
108
|
-
if (key === "cwd"
|
|
114
|
+
if (key === "cwd") {
|
|
115
|
+
if (!policy.captureCwd) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
output[key] = typeof value === "string" && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)
|
|
119
|
+
? hashPath(value)
|
|
120
|
+
: redactValue(value);
|
|
109
121
|
continue;
|
|
110
122
|
}
|
|
111
123
|
output[key] = redactValue(value);
|
package/src/commands.ts
CHANGED
|
@@ -75,7 +75,7 @@ function isPrivacyPreset(value: string | undefined): value is PrivacyPreset {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function inferPreset(policy: CapturePolicy): PrivacyPreset | "custom" {
|
|
78
|
-
const entries: Array<[PrivacyPreset, CapturePolicy]> = [
|
|
78
|
+
const entries: Array<[PrivacyPreset, Omit<CapturePolicy, "captureSourceMetadata">]> = [
|
|
79
79
|
[
|
|
80
80
|
"metadata-only",
|
|
81
81
|
{
|
|
@@ -139,6 +139,7 @@ function describePolicy(policy: CapturePolicy) {
|
|
|
139
139
|
`captureToolIo: ${policy.captureToolIo}`,
|
|
140
140
|
`captureSystemPrompt: ${policy.captureSystemPrompt}`,
|
|
141
141
|
`captureCwd: ${policy.captureCwd}`,
|
|
142
|
+
`captureSourceMetadata: ${policy.captureSourceMetadata}`,
|
|
142
143
|
].join("\n");
|
|
143
144
|
}
|
|
144
145
|
|
package/src/handlers/agent.ts
CHANGED
|
@@ -59,7 +59,8 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
|
|
|
59
59
|
images: event.images,
|
|
60
60
|
context: event.context ?? event.attachments,
|
|
61
61
|
});
|
|
62
|
-
const
|
|
62
|
+
const capturePolicy = getCapturePolicy();
|
|
63
|
+
const sourceMetadata = collectSourceMetadata(cwd, capturePolicy.captureSourceMetadata);
|
|
63
64
|
const captured = applyCapturePolicy(
|
|
64
65
|
{
|
|
65
66
|
input: rawPromptInput,
|
|
@@ -71,7 +72,7 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
|
|
|
71
72
|
sessionId: state.currentSessionId || undefined,
|
|
72
73
|
},
|
|
73
74
|
},
|
|
74
|
-
|
|
75
|
+
capturePolicy,
|
|
75
76
|
);
|
|
76
77
|
|
|
77
78
|
state.agentState = {
|
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) {
|
|
@@ -587,7 +645,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
|
|
|
587
645
|
}
|
|
588
646
|
|
|
589
647
|
const trace = store.trace;
|
|
590
|
-
const
|
|
648
|
+
const entries: any[] = [
|
|
591
649
|
{
|
|
592
650
|
type: "trace-create",
|
|
593
651
|
id: randomUUID(),
|
|
@@ -627,7 +685,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
|
|
|
627
685
|
}
|
|
628
686
|
: {}),
|
|
629
687
|
};
|
|
630
|
-
|
|
688
|
+
entries.push({
|
|
631
689
|
type: observation.type === "GENERATION" ? "generation-create" : "span-create",
|
|
632
690
|
id: randomUUID(),
|
|
633
691
|
timestamp: eventTimestamp(observation),
|
|
@@ -635,12 +693,39 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
|
|
|
635
693
|
});
|
|
636
694
|
}
|
|
637
695
|
|
|
638
|
-
const
|
|
696
|
+
const maxTotalBytes = getMaxFallbackTotalBytes();
|
|
697
|
+
const totalBytes = serializedBytes({ batch: entries });
|
|
698
|
+
if (totalBytes > maxTotalBytes) {
|
|
699
|
+
const message = `REST fallback payload is ${(totalBytes / 1024 / 1024).toFixed(1)}MB, above the ${(maxTotalBytes / 1024 / 1024).toFixed(1)}MB ceiling; skipping fallback ingestion`;
|
|
700
|
+
rememberRuntimeError("REST fallback ingestion", new Error(message));
|
|
701
|
+
console.warn(`📊 Langfuse: ${message}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const chunks = splitIngestionBatch(entries, getMaxIngestionBatchBytes());
|
|
706
|
+
const errors: unknown[] = [];
|
|
707
|
+
for (const chunk of chunks) {
|
|
708
|
+
if (signal.aborted) {
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
errors.push(...(await ingestBatch(rt, chunk, signal)));
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (signal.aborted && isAbortError(error)) {
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
rememberRuntimeError("REST fallback ingestion", error);
|
|
718
|
+
console.warn("📊 Langfuse: REST fallback ingestion chunk failed", error);
|
|
719
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
639
722
|
if (errors.length > 0) {
|
|
640
723
|
rememberRuntimeError("REST fallback ingestion", new Error(JSON.stringify(errors)));
|
|
641
724
|
console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
|
|
642
725
|
} else {
|
|
643
|
-
debugLog(
|
|
726
|
+
debugLog(
|
|
727
|
+
`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion (${chunks.length} chunk(s))`,
|
|
728
|
+
);
|
|
644
729
|
}
|
|
645
730
|
}
|
|
646
731
|
|
|
@@ -660,6 +745,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
660
745
|
if (!runtime) {
|
|
661
746
|
const [
|
|
662
747
|
{ BasicTracerProvider },
|
|
748
|
+
{ resources },
|
|
663
749
|
{ context },
|
|
664
750
|
{ AsyncHooksContextManager },
|
|
665
751
|
{ LangfuseSpanProcessor },
|
|
@@ -667,6 +753,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
667
753
|
{ LangfuseClient },
|
|
668
754
|
] = await Promise.all([
|
|
669
755
|
import("@opentelemetry/sdk-trace-base"),
|
|
756
|
+
import("@opentelemetry/sdk-node"),
|
|
670
757
|
import("@opentelemetry/api"),
|
|
671
758
|
import("@opentelemetry/context-async-hooks"),
|
|
672
759
|
import("@langfuse/otel"),
|
|
@@ -692,7 +779,10 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
692
779
|
secretKey: state.config.secretKey,
|
|
693
780
|
baseUrl: state.config.host,
|
|
694
781
|
});
|
|
695
|
-
const
|
|
782
|
+
const resource = resources.defaultResource().merge(
|
|
783
|
+
resources.detectResources({ detectors: [resources.envDetector] }),
|
|
784
|
+
);
|
|
785
|
+
const tracerProvider = new BasicTracerProvider({ resource, spanProcessors: [spanProcessor] });
|
|
696
786
|
tracing.setLangfuseTracerProvider(tracerProvider);
|
|
697
787
|
|
|
698
788
|
runtime = {
|
package/src/source-metadata.ts
CHANGED
|
@@ -1,160 +1,73 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
3
1
|
import { execFileSync } from "node:child_process";
|
|
4
2
|
|
|
5
3
|
export type SourceMetadata = Record<string, string>;
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"source_type",
|
|
12
|
-
"service_name",
|
|
13
|
-
"project_slug",
|
|
14
|
-
"environment",
|
|
15
|
-
"observability_owner",
|
|
16
|
-
]);
|
|
5
|
+
type GitResult =
|
|
6
|
+
| { status: "ok"; value: string }
|
|
7
|
+
| { status: "failed" }
|
|
8
|
+
| { status: "unavailable" };
|
|
17
9
|
|
|
18
|
-
function
|
|
10
|
+
function sourceStatus(status: "disabled" | "non-git" | "unavailable"): SourceMetadata {
|
|
19
11
|
return {
|
|
20
|
-
source_type:
|
|
21
|
-
metadata_source:
|
|
12
|
+
source_type: status,
|
|
13
|
+
metadata_source: status,
|
|
22
14
|
};
|
|
23
15
|
}
|
|
24
16
|
|
|
25
|
-
function runGit(cwd: string, args: string[]):
|
|
26
|
-
return execFileSync("git", args, {
|
|
27
|
-
cwd,
|
|
28
|
-
encoding: "utf8",
|
|
29
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
30
|
-
timeout: 2000,
|
|
31
|
-
}).trim();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function firstLine(value: string): string {
|
|
35
|
-
return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function sanitizeGitRemote(remoteUrl: string): Partial<Pick<SourceMetadata, "git_remote_host" | "git_remote_path">> {
|
|
39
|
-
const raw = firstLine(remoteUrl).replace(/\.git$/i, "");
|
|
40
|
-
if (!raw) {
|
|
41
|
-
return {};
|
|
42
|
-
}
|
|
43
|
-
|
|
17
|
+
function runGit(cwd: string, args: string[], env: NodeJS.ProcessEnv): GitResult {
|
|
44
18
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function deriveIdentity(remotePath: string | undefined): Partial<Pick<SourceMetadata, "repo_identity" | "repo_owner" | "repo_name">> {
|
|
63
|
-
if (!remotePath) {
|
|
64
|
-
return {};
|
|
65
|
-
}
|
|
66
|
-
const parts = remotePath.split("/").filter(Boolean);
|
|
67
|
-
if (parts.length < 2) {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
const repoName = parts[parts.length - 1];
|
|
71
|
-
const owner = parts[parts.length - 2];
|
|
72
|
-
if (!repoName || !owner || repoName.includes("/")) {
|
|
73
|
-
return {};
|
|
19
|
+
return {
|
|
20
|
+
status: "ok",
|
|
21
|
+
value: execFileSync("git", args, {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
env,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
timeout: 2000,
|
|
27
|
+
}).trim(),
|
|
28
|
+
};
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const failure = error as NodeJS.ErrnoException & { status?: number | null };
|
|
31
|
+
return typeof failure.status === "number"
|
|
32
|
+
? { status: "failed" }
|
|
33
|
+
: { status: "unavailable" };
|
|
74
34
|
}
|
|
75
|
-
return {
|
|
76
|
-
repo_identity: `${owner}/${repoName}`,
|
|
77
|
-
repo_owner: owner,
|
|
78
|
-
repo_name: repoName,
|
|
79
|
-
};
|
|
80
35
|
}
|
|
81
36
|
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
if (current === root) {
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
const parent = dirname(current);
|
|
94
|
-
if (parent === current) {
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
current = parent;
|
|
37
|
+
export function collectSourceMetadata(
|
|
38
|
+
cwd: string,
|
|
39
|
+
enabled: boolean,
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
): SourceMetadata {
|
|
42
|
+
if (!enabled) {
|
|
43
|
+
return sourceStatus("disabled");
|
|
98
44
|
}
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
45
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return {};
|
|
46
|
+
const inside = runGit(cwd, ["rev-parse", "--is-inside-work-tree"], env);
|
|
47
|
+
if (inside.status === "unavailable") {
|
|
48
|
+
return sourceStatus("unavailable");
|
|
106
49
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const output: SourceMetadata = {};
|
|
110
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
111
|
-
if (OVERRIDE_KEYS.has(key) && typeof value === "string" && value.trim()) {
|
|
112
|
-
output[key] = value.trim();
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (output.repo_name?.includes("/")) {
|
|
116
|
-
delete output.repo_name;
|
|
117
|
-
}
|
|
118
|
-
return output;
|
|
119
|
-
} catch {
|
|
120
|
-
return {};
|
|
50
|
+
if (inside.status !== "ok" || inside.value !== "true") {
|
|
51
|
+
return sourceStatus("non-git");
|
|
121
52
|
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function collectSourceMetadata(cwd: string): SourceMetadata {
|
|
125
|
-
try {
|
|
126
|
-
const inside = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
127
|
-
if (inside !== "true") {
|
|
128
|
-
return nonGitMetadata();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const gitRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
132
|
-
const commit = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
133
|
-
const branch = runGit(cwd, ["branch", "--show-current"]);
|
|
134
|
-
const remote = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
135
|
-
const remoteMetadata = sanitizeGitRemote(remote);
|
|
136
|
-
const derivedIdentity = deriveIdentity(remoteMetadata.git_remote_path);
|
|
137
|
-
const overrides = readWhitelistedOverrides(cwd, gitRoot);
|
|
138
|
-
|
|
139
|
-
const metadata: SourceMetadata = {
|
|
140
|
-
source_type: "git-repo",
|
|
141
|
-
repo_root_name: basename(gitRoot),
|
|
142
|
-
...(branch ? { git_branch: branch } : {}),
|
|
143
|
-
git_commit: commit,
|
|
144
|
-
...remoteMetadata,
|
|
145
|
-
...derivedIdentity,
|
|
146
|
-
...overrides,
|
|
147
|
-
metadata_source: Object.keys(overrides).length > 0 ? "repo-file" : "git-detection",
|
|
148
|
-
};
|
|
149
53
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
54
|
+
const revision = runGit(cwd, ["rev-parse", "--verify", "HEAD"], env);
|
|
55
|
+
const ref = runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], env);
|
|
56
|
+
const workingTree = runGit(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"], env);
|
|
57
|
+
if (
|
|
58
|
+
revision.status !== "ok" ||
|
|
59
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(revision.value) ||
|
|
60
|
+
workingTree.status !== "ok" ||
|
|
61
|
+
ref.status === "unavailable"
|
|
62
|
+
) {
|
|
63
|
+
return sourceStatus("unavailable");
|
|
159
64
|
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
source_type: "git-repo",
|
|
68
|
+
"vcs.ref.head.revision": revision.value,
|
|
69
|
+
git_detached: String(ref.status !== "ok"),
|
|
70
|
+
git_dirty: String(Boolean(workingTree.value)),
|
|
71
|
+
metadata_source: "git-detection",
|
|
72
|
+
};
|
|
160
73
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
declare module "@opentelemetry/sdk-trace-base" {
|
|
2
2
|
export class BasicTracerProvider {
|
|
3
|
-
constructor(options?: { spanProcessors?: unknown[] });
|
|
3
|
+
constructor(options?: { resource?: unknown; spanProcessors?: unknown[] });
|
|
4
4
|
forceFlush?(): Promise<void>;
|
|
5
5
|
shutdown?(): Promise<void>;
|
|
6
6
|
}
|