pi-langfuse 1.5.16 → 1.5.17

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
@@ -109,12 +109,38 @@ export LANGFUSE_CAPTURE_TOOL_IO=false
109
109
  export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
110
110
  export LANGFUSE_CAPTURE_CWD=false
111
111
  export LANGFUSE_CAPTURE_SOURCE_METADATA=false
112
+ export LANGFUSE_CAPTURE_PATHS=false
112
113
  ```
113
114
 
114
115
  Source metadata remains off in every preset unless `LANGFUSE_CAPTURE_SOURCE_METADATA=true` is set explicitly.
116
+ The same holds for absolute paths and `LANGFUSE_CAPTURE_PATHS`.
115
117
 
116
118
  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.
117
119
 
120
+ ### Absolute paths
121
+
122
+ By default, local absolute paths (`/Users/...`, `/home/...`, `/tmp/...`, `C:\Users\...`) are
123
+ replaced everywhere with a stable `[PATH_HASH:<12 hex chars>]` digest, so usernames and repository
124
+ names never reach Langfuse. This applies to inputs, outputs, tool I/O, tool error messages, and the
125
+ `cwd` metadata field. Opt in to see real paths in traces:
126
+
127
+ ```bash
128
+ export LANGFUSE_CAPTURE_PATHS=true
129
+ ```
130
+
131
+ Or persist it in `config.json`:
132
+
133
+ ```json
134
+ { "capture": { "LANGFUSE_CAPTURE_PATHS": "true" } }
135
+ ```
136
+
137
+ Like `LANGFUSE_CAPTURE_SOURCE_METADATA`, this stays off in every privacy preset until it is set
138
+ explicitly, and it only affects paths — secret masking (tokens, keys, cookies, passwords) is always
139
+ on regardless. Note that `LANGFUSE_CAPTURE_CWD=false` is a different control: it drops the `cwd`
140
+ metadata field entirely rather than changing how paths are rendered.
141
+
142
+ `/langfuse-status` reports the current setting under `Capture: absolute paths`.
143
+
118
144
  ### Payload limits
119
145
 
120
146
  Before upload, payloads are shaped: strings are truncated and deeply nested or
package/README_CN.md CHANGED
@@ -109,12 +109,36 @@ export LANGFUSE_CAPTURE_TOOL_IO=false
109
109
  export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
110
110
  export LANGFUSE_CAPTURE_CWD=false
111
111
  export LANGFUSE_CAPTURE_SOURCE_METADATA=false
112
+ export LANGFUSE_CAPTURE_PATHS=false
112
113
  ```
113
114
 
114
115
  所有隐私预设默认都关闭源码元数据;只有显式设置 `LANGFUSE_CAPTURE_SOURCE_METADATA=true` 才会启用。
116
+ 绝对路径与 `LANGFUSE_CAPTURE_PATHS` 同理。
115
117
 
116
118
  所有被采集的负载在上传前仍会脱敏。扩展会隐藏常见 API key、Bearer token、密码、Cookie、私钥、Langfuse key、GitHub/npm/AWS 风格 token,并对本地绝对路径做 hash。
117
119
 
120
+ ### 绝对路径
121
+
122
+ 默认情况下,本地绝对路径(`/Users/...`、`/home/...`、`/tmp/...`、`C:\Users\...`)会在所有位置被替换为
123
+ 稳定的 `[PATH_HASH:<12 位十六进制>]` 摘要,因此用户名和仓库名不会进入 Langfuse。该规则作用于输入、输出、
124
+ 工具 I/O、工具错误信息以及 `cwd` 元数据字段。若希望在 trace 中看到真实路径,需要显式开启:
125
+
126
+ ```bash
127
+ export LANGFUSE_CAPTURE_PATHS=true
128
+ ```
129
+
130
+ 也可以持久化到 `config.json`:
131
+
132
+ ```json
133
+ { "capture": { "LANGFUSE_CAPTURE_PATHS": "true" } }
134
+ ```
135
+
136
+ 与 `LANGFUSE_CAPTURE_SOURCE_METADATA` 一样,它在所有隐私预设中默认关闭,且只影响路径;密钥脱敏(token、key、
137
+ Cookie、密码)始终生效。注意 `LANGFUSE_CAPTURE_CWD=false` 是另一个控制项——它会直接丢弃 `cwd` 元数据字段,
138
+ 而不是改变路径的呈现方式。
139
+
140
+ `/langfuse-status` 会在 `Capture: absolute paths` 下显示当前设置。
141
+
118
142
  ### 负载上限
119
143
 
120
144
  上传前会对负载做整形:字符串会被截断,过深或过宽的结构会被裁剪。这些上限让 trace 保持精简,同时保护
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.16",
3
+ "version": "1.5.17",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { hashPath, redactValue } from "./redaction.js";
1
+ import { hashPath, redactValue, type RedactOptions } from "./redaction.js";
2
2
 
3
3
  export interface CapturePolicy {
4
4
  readonly captureInputs: boolean;
@@ -7,6 +7,17 @@ export interface CapturePolicy {
7
7
  readonly captureSystemPrompt: boolean;
8
8
  readonly captureCwd: boolean;
9
9
  readonly captureSourceMetadata: boolean;
10
+ /**
11
+ * Capture local absolute paths verbatim. Off in every preset, like
12
+ * `captureSourceMetadata`: paths are replaced with `[PATH_HASH:...]` unless
13
+ * `LANGFUSE_CAPTURE_PATHS` opts in explicitly.
14
+ */
15
+ readonly capturePaths: boolean;
16
+ }
17
+
18
+ /** Redaction options implied by a policy, so path rendering follows the same switch everywhere. */
19
+ export function redactOptionsFor(policy: CapturePolicy): Partial<RedactOptions> {
20
+ return { redactPaths: !policy.capturePaths };
10
21
  }
11
22
 
12
23
  export type PrivacyPreset = "metadata-only" | "prompts-only" | "conversations" | "full-debug";
@@ -38,6 +49,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
38
49
  captureSystemPrompt: false,
39
50
  captureCwd: false,
40
51
  captureSourceMetadata: false,
52
+ capturePaths: false,
41
53
  },
42
54
  "prompts-only": {
43
55
  captureInputs: true,
@@ -46,6 +58,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
46
58
  captureSystemPrompt: false,
47
59
  captureCwd: false,
48
60
  captureSourceMetadata: false,
61
+ capturePaths: false,
49
62
  },
50
63
  conversations: {
51
64
  captureInputs: true,
@@ -54,6 +67,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
54
67
  captureSystemPrompt: false,
55
68
  captureCwd: false,
56
69
  captureSourceMetadata: false,
70
+ capturePaths: false,
57
71
  },
58
72
  "full-debug": {
59
73
  captureInputs: true,
@@ -62,6 +76,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
62
76
  captureSystemPrompt: true,
63
77
  captureCwd: true,
64
78
  captureSourceMetadata: false,
79
+ capturePaths: false,
65
80
  },
66
81
  };
67
82
 
@@ -72,6 +87,7 @@ const FLAG_TO_FIELD = {
72
87
  LANGFUSE_CAPTURE_SYSTEM_PROMPT: "captureSystemPrompt",
73
88
  LANGFUSE_CAPTURE_CWD: "captureCwd",
74
89
  LANGFUSE_CAPTURE_SOURCE_METADATA: "captureSourceMetadata",
90
+ LANGFUSE_CAPTURE_PATHS: "capturePaths",
75
91
  } as const;
76
92
 
77
93
  function parseFlag(value: string | undefined): boolean | undefined {
@@ -109,18 +125,20 @@ function redactMetadata(metadata: Record<string, unknown> | undefined, policy: C
109
125
  return undefined;
110
126
  }
111
127
 
128
+ const redactOptions = redactOptionsFor(policy);
112
129
  const output: Record<string, unknown> = {};
113
130
  for (const [key, value] of Object.entries(metadata)) {
114
131
  if (key === "cwd") {
115
132
  if (!policy.captureCwd) {
116
133
  continue;
117
134
  }
118
- output[key] = typeof value === "string" && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)
119
- ? hashPath(value)
120
- : redactValue(value);
135
+ output[key] =
136
+ !policy.capturePaths && typeof value === "string" && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)
137
+ ? hashPath(value)
138
+ : redactValue(value, redactOptions);
121
139
  continue;
122
140
  }
123
- output[key] = redactValue(value);
141
+ output[key] = redactValue(value, redactOptions);
124
142
  }
125
143
  return Object.keys(output).length > 0 ? output : undefined;
126
144
  }
@@ -129,24 +147,25 @@ export function applyCapturePolicy(
129
147
  payload: RawTelemetryPayload,
130
148
  policy: CapturePolicy = createCapturePolicy(),
131
149
  ): CapturedTelemetryPayload {
150
+ const redactOptions = redactOptionsFor(policy);
132
151
  const captured: CapturedTelemetryPayload = {
133
152
  metadata: redactMetadata(payload.metadata, policy),
134
153
  };
135
154
 
136
155
  if (policy.captureInputs && "input" in payload) {
137
- captured.input = redactValue(payload.input);
156
+ captured.input = redactValue(payload.input, redactOptions);
138
157
  }
139
158
  if (policy.captureOutputs && "output" in payload) {
140
- captured.output = redactValue(payload.output);
159
+ captured.output = redactValue(payload.output, redactOptions);
141
160
  }
142
161
  if (policy.captureToolIo && "toolInput" in payload) {
143
- captured.toolInput = redactValue(payload.toolInput);
162
+ captured.toolInput = redactValue(payload.toolInput, redactOptions);
144
163
  }
145
164
  if (policy.captureToolIo && "toolOutput" in payload) {
146
- captured.toolOutput = redactValue(payload.toolOutput);
165
+ captured.toolOutput = redactValue(payload.toolOutput, redactOptions);
147
166
  }
148
167
  if (policy.captureSystemPrompt && "systemPrompt" in payload) {
149
- captured.systemPrompt = redactValue(payload.systemPrompt);
168
+ captured.systemPrompt = redactValue(payload.systemPrompt, redactOptions);
150
169
  }
151
170
 
152
171
  return captured;
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, Omit<CapturePolicy, "captureSourceMetadata">]> = [
78
+ const entries: Array<[PrivacyPreset, Omit<CapturePolicy, "captureSourceMetadata" | "capturePaths">]> = [
79
79
  [
80
80
  "metadata-only",
81
81
  {
@@ -140,6 +140,7 @@ function describePolicy(policy: CapturePolicy) {
140
140
  `captureSystemPrompt: ${policy.captureSystemPrompt}`,
141
141
  `captureCwd: ${policy.captureCwd}`,
142
142
  `captureSourceMetadata: ${policy.captureSourceMetadata}`,
143
+ `capturePaths: ${policy.capturePaths}`,
143
144
  ].join("\n");
144
145
  }
145
146
 
@@ -218,6 +219,7 @@ function formatStatus(configPath: string, env: Record<string, string | undefined
218
219
  ` tool IO: ${flag(policy.captureToolIo)}`,
219
220
  ` system prompt: ${flag(policy.captureSystemPrompt)}`,
220
221
  ` cwd: ${flag(policy.captureCwd)}`,
222
+ ` absolute paths: ${flag(policy.capturePaths)}`,
221
223
  `Active run: ${hasActiveAgentObservation() ? "yes" : "no"}`,
222
224
  `Last error: ${lastErrorSummary()}`,
223
225
  ].join("\n");
@@ -12,7 +12,7 @@ import {
12
12
  getCapturePolicy,
13
13
  getLimits,
14
14
  } from "../utils.js";
15
- import { applyCapturePolicy } from "../capture-policy.js";
15
+ import { applyCapturePolicy, redactOptionsFor } from "../capture-policy.js";
16
16
  import { redactString } from "../redaction.js";
17
17
 
18
18
  export async function startToolObservation(event: Record<string, unknown>) {
@@ -87,6 +87,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
87
87
  event;
88
88
 
89
89
  try {
90
+ const policy = getCapturePolicy();
90
91
  const shapedOutput = shapePayload(output, { maxString: getLimits().maxToolPayload });
91
92
  const captured = applyCapturePolicy(
92
93
  {
@@ -97,7 +98,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
97
98
  isError,
98
99
  },
99
100
  },
100
- getCapturePolicy(),
101
+ policy,
101
102
  );
102
103
  const outputBytes = estimatePayloadBytes(captured.toolOutput, getLimits().maxToolPayload);
103
104
  const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
@@ -106,7 +107,9 @@ export async function finishToolObservation(event: Record<string, unknown>) {
106
107
  .update({
107
108
  output: captured.toolOutput,
108
109
  level: isError ? "ERROR" : "DEFAULT",
109
- statusMessage: isError ? redactString(truncate(String(event.error ?? output), 1_000)) : undefined,
110
+ statusMessage: isError
111
+ ? redactString(truncate(String(event.error ?? output), 1_000), redactOptionsFor(policy))
112
+ : undefined,
110
113
  metadata: {
111
114
  ...(captured.metadata ?? {}),
112
115
  durationMs,
package/src/redaction.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { getLimits } from "./limits.js";
3
+ import { state } from "./state.js";
3
4
 
4
5
  export const REDACTED = "[REDACTED_SECRET]";
5
6
 
@@ -8,8 +9,16 @@ export interface RedactOptions {
8
9
  maxArrayItems: number;
9
10
  maxObjectKeys: number;
10
11
  maxStringLength: number;
12
+ /** When false, absolute filesystem paths are emitted verbatim instead of `[PATH_HASH:...]`. */
13
+ redactPaths: boolean;
11
14
  }
12
15
 
16
+ /**
17
+ * Fallback for callers that do not pass the resolved capture policy. Mirrors
18
+ * `getLimits()` by reading the session config, and defaults to redacting so a
19
+ * missing policy can never widen disclosure. Callers that hold a policy should
20
+ * pass `redactOptionsFor(policy)` instead of relying on this.
21
+ */
13
22
  function defaultOptions(): RedactOptions {
14
23
  const limits = getLimits();
15
24
  return {
@@ -17,6 +26,7 @@ function defaultOptions(): RedactOptions {
17
26
  maxArrayItems: limits.maxArrayItems,
18
27
  maxObjectKeys: limits.maxObjectKeys,
19
28
  maxStringLength: limits.maxString,
29
+ redactPaths: !(state.config?.capturePolicy?.capturePaths ?? false),
20
30
  };
21
31
  }
22
32
 
@@ -41,16 +51,20 @@ function truncate(value: string, maxStringLength: number): string {
41
51
 
42
52
  export function redactString(value: string, options: Partial<RedactOptions> = {}): string {
43
53
  const merged = { ...defaultOptions(), ...options };
44
- const truncated = truncate(value, merged.maxStringLength);
45
- return truncated
54
+ const secretsRedacted = truncate(value, merged.maxStringLength)
46
55
  .replace(PRIVATE_KEY_RE, REDACTED)
47
56
  .replace(BEARER_RE, REDACTED)
48
57
  .replace(KNOWN_TOKEN_RE, REDACTED)
49
- .replace(SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=${REDACTED}`)
50
- .replace(ABSOLUTE_PATH_RE, (path: string) => {
51
- const envSuffix = path.match(/([/\\]\.env(?:\.[A-Za-z0-9_-]+)?)$/)?.[1];
52
- return `${hashPath(envSuffix ? path.slice(0, -envSuffix.length) : path)}${envSuffix ?? ""}`;
53
- });
58
+ .replace(SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=${REDACTED}`);
59
+
60
+ if (!merged.redactPaths) {
61
+ return secretsRedacted;
62
+ }
63
+
64
+ return secretsRedacted.replace(ABSOLUTE_PATH_RE, (path: string) => {
65
+ const envSuffix = path.match(/([/\\]\.env(?:\.[A-Za-z0-9_-]+)?)$/)?.[1];
66
+ return `${hashPath(envSuffix ? path.slice(0, -envSuffix.length) : path)}${envSuffix ?? ""}`;
67
+ });
54
68
  }
55
69
 
56
70
  function visit(value: unknown, options: RedactOptions, depth: number, seen: WeakSet<object>): unknown {
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getLimits } from "./limits.js";
2
- import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
2
+ import { createCapturePolicy, redactOptionsFor, type CapturePolicy } from "./capture-policy.js";
3
3
  import { redactValue } from "./redaction.js";
4
4
  import { state } from "./state.js";
5
5
 
@@ -140,6 +140,7 @@ export function shapePayload(
140
140
  return options.redact === false
141
141
  ? shaped
142
142
  : redactValue(shaped, {
143
+ ...redactOptionsFor(getCapturePolicy()),
143
144
  maxDepth: depth,
144
145
  maxStringLength: maxString,
145
146
  maxArrayItems,
@@ -369,6 +370,58 @@ export function getMessageFromEvent(event: Record<string, unknown>): Record<stri
369
370
  return undefined;
370
371
  }
371
372
 
373
+ /**
374
+ * Langfuse buckets usage and cost details by substring match on the key name:
375
+ * `input` rolls up into the Input row of the breakdown, `output` into Output,
376
+ * and anything matching neither falls into the catch-all Other row. Camel-cased
377
+ * `cacheWrite` lands in Other, so cache is reported outside the Input bucket it
378
+ * belongs to and never reaches Langfuse's own price table, which resolves
379
+ * usage types by exact key match (`price.usageType === key`) and therefore
380
+ * cannot price cache for providers that do not report cost themselves.
381
+ *
382
+ * These are the key names Langfuse documents for Anthropic-style caching.
383
+ * Pi reports `input` exclusive of cached tokens, so the buckets stay
384
+ * non-overlapping and nothing is double counted. Emitting both spellings would
385
+ * be worse than either: the canonical key would be counted in Input and the
386
+ * camel-cased duplicate again in Other.
387
+ */
388
+ const CACHE_READ_KEY = "cache_read_input_tokens";
389
+ const CACHE_WRITE_KEY = "cache_creation_input_tokens";
390
+
391
+ /**
392
+ * Anthropic prices one-hour cache writes at a different rate from the default
393
+ * five-minute ones, and Langfuse mirrors that with two TTL-specific usage keys.
394
+ * Pi reports the total under `cacheWrite` and the hour-TTL share of it under
395
+ * `cacheWrite1h`, so the two Langfuse buckets are `cacheWrite - cacheWrite1h`
396
+ * and `cacheWrite1h`. Both keys contain `input`, so the Input row of the
397
+ * breakdown is unchanged; only the price lookup differs.
398
+ */
399
+ const CACHE_WRITE_5M_KEY = "input_cache_creation_5m";
400
+ const CACHE_WRITE_1H_KEY = "input_cache_creation_1h";
401
+
402
+ /**
403
+ * Pi emits `cacheWrite1h: 0` on every Anthropic-family response, so a zero
404
+ * carries no TTL information. Only a non-zero hour-TTL figure is a real
405
+ * breakdown; everything else stays on the TTL-agnostic key so dashboards and
406
+ * custom model prices keyed on it keep working.
407
+ */
408
+ function splitCacheWrite(cacheWrite: number, cacheWrite1h: number): Record<string, number> {
409
+ if (!cacheWrite) {
410
+ return {};
411
+ }
412
+
413
+ const longWrite = Math.min(Math.max(cacheWrite1h, 0), cacheWrite);
414
+ if (!longWrite) {
415
+ return { [CACHE_WRITE_KEY]: cacheWrite };
416
+ }
417
+
418
+ const shortWrite = cacheWrite - longWrite;
419
+ return {
420
+ ...(shortWrite ? { [CACHE_WRITE_5M_KEY]: shortWrite } : {}),
421
+ [CACHE_WRITE_1H_KEY]: longWrite,
422
+ };
423
+ }
424
+
372
425
  export function extractUsage(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
373
426
  const usage = (messageOrEvent.usage ??
374
427
  (messageOrEvent.message && typeof messageOrEvent.message === "object"
@@ -383,13 +436,14 @@ export function extractUsage(messageOrEvent: Record<string, unknown>): Record<st
383
436
  const total = Number(usage.total ?? usage.totalTokens ?? usage.total_tokens ?? input + output);
384
437
  const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? 0);
385
438
  const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
439
+ const cacheWrite1h = Number(usage.cacheWrite1h ?? usage.cache_write_1h ?? 0);
386
440
 
387
441
  return {
388
442
  input,
389
443
  output,
390
444
  total,
391
- ...(cacheRead ? { cacheRead } : {}),
392
- ...(cacheWrite ? { cacheWrite } : {}),
445
+ ...(cacheRead ? { [CACHE_READ_KEY]: cacheRead } : {}),
446
+ ...splitCacheWrite(cacheWrite, cacheWrite1h),
393
447
  };
394
448
  }
395
449
 
@@ -405,12 +459,23 @@ export function extractCostDetails(messageOrEvent: Record<string, unknown>): Rec
405
459
 
406
460
  const input = Number(cost.input ?? cost.inputCost ?? 0);
407
461
  const output = Number(cost.output ?? cost.outputCost ?? 0);
408
- const total = Number(cost.total ?? cost.totalCost ?? input + output);
409
- if (input === 0 && output === 0 && total === 0) {
462
+ const cacheRead = Number(cost.cacheRead ?? cost.cache_read ?? 0);
463
+ // Pi has no per-TTL cost figure: `cost.cacheWrite` already prices the
464
+ // five-minute and one-hour shares at their own rates and sums them, so the
465
+ // cost side stays on the aggregate key even when usage is split by TTL.
466
+ const cacheWrite = Number(cost.cacheWrite ?? cost.cache_write ?? 0);
467
+ const total = Number(cost.total ?? cost.totalCost ?? input + output + cacheRead + cacheWrite);
468
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0 && total === 0) {
410
469
  return undefined;
411
470
  }
412
471
 
413
- return { input, output, total };
472
+ return {
473
+ input,
474
+ output,
475
+ total,
476
+ ...(cacheRead ? { [CACHE_READ_KEY]: cacheRead } : {}),
477
+ ...(cacheWrite ? { [CACHE_WRITE_KEY]: cacheWrite } : {}),
478
+ };
414
479
  }
415
480
 
416
481
  export function extractResponseMetadata(event: Record<string, unknown>): Record<string, unknown> {