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 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
- Local prototype note: source metadata support in this installed package is a local prototype patch. A durable solution should be shipped through an upstream PR, a fork, or a maintained package version so reinstalling the extension does not lose the behavior.
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-backed runs, the extension attaches safe source metadata to traces:
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
- "repo_identity": "owner/repo",
209
- "repo_owner": "owner",
210
- "repo_name": "repo",
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
- `repo_identity` is `owner/repo`. `repo_name` is the repo name only and must not contain a slash.
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
- A Git repo may optionally provide `.pi-langfuse.metadata.json`. Overrides are whitelist-only; unknown keys are ignored. Allowed keys are:
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
- ```text
225
- repo_identity
226
- repo_owner
227
- repo_name
228
- source_type
229
- service_name
230
- project_slug
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
- Repo-local overrides are used only after the working directory is confirmed to be inside a usable Git repo. If Git detection fails for any reason, including a missing Git command, corrupted repo, or non-Git folder, the extension ignores repo-local identity files and emits only:
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
- ```json
238
- {
239
- "source_type": "non-git",
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
- The extension must not upload raw absolute local paths, credentialed remotes, tokens, unknown override keys, or folder names for non-Git folders.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.13",
3
+ "version": "1.5.15",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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" && !policy.captureCwd) {
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
 
@@ -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 sourceMetadata = collectSourceMetadata(cwd);
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
- getCapturePolicy(),
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 batch: any[] = [
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
- batch.push({
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 errors = await ingestBatch(rt, batch, signal);
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(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
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 tracerProvider = new BasicTracerProvider({ spanProcessors: [spanProcessor] });
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 = {
@@ -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
- const OVERRIDE_KEYS = new Set([
8
- "repo_identity",
9
- "repo_owner",
10
- "repo_name",
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 nonGitMetadata(): SourceMetadata {
10
+ function sourceStatus(status: "disabled" | "non-git" | "unavailable"): SourceMetadata {
19
11
  return {
20
- source_type: "non-git",
21
- metadata_source: "non-git",
12
+ source_type: status,
13
+ metadata_source: status,
22
14
  };
23
15
  }
24
16
 
25
- function runGit(cwd: string, args: string[]): 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
- const parsed = new URL(raw);
46
- const path = parsed.pathname.replace(/^\/+/, "").replace(/\.git$/i, "");
47
- return parsed.hostname && path ? { git_remote_host: parsed.hostname, git_remote_path: path } : {};
48
- } catch {
49
- // Continue with scp-like SSH syntax, for example git@github.com:owner/repo.git.
50
- }
51
-
52
- const scpLike = raw.match(/^(?:[^@\s/:]+@)?([^:\s]+):(.+)$/);
53
- if (scpLike) {
54
- const host = scpLike[1];
55
- const path = scpLike[2].replace(/^\/+/, "").replace(/\.git$/i, "");
56
- return host && path && !path.includes("@") ? { git_remote_host: host, git_remote_path: path } : {};
57
- }
58
-
59
- return {};
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 findRepoMetadataFile(cwd: string, gitRoot: string): string | undefined {
83
- let current = cwd;
84
- const root = gitRoot;
85
- while (true) {
86
- const candidate = join(current, ".pi-langfuse.metadata.json");
87
- if (existsSync(candidate)) {
88
- return candidate;
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
- function readWhitelistedOverrides(cwd: string, gitRoot: string): SourceMetadata {
103
- const path = findRepoMetadataFile(cwd, gitRoot);
104
- if (!path) {
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
- try {
108
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
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
- if (metadata.repo_name?.includes("/")) {
151
- delete metadata.repo_name;
152
- }
153
- if (!metadata.repo_identity && metadata.repo_owner && metadata.repo_name) {
154
- metadata.repo_identity = `${metadata.repo_owner}/${metadata.repo_name}`;
155
- }
156
- return metadata;
157
- } catch {
158
- return nonGitMetadata();
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
  }