pi-langfuse 1.5.3 → 1.5.5

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
@@ -57,6 +57,14 @@ To run setup again:
57
57
  /langfuse-setup
58
58
  ```
59
59
 
60
+ To inspect the active configuration without exposing secrets:
61
+
62
+ ```text
63
+ /langfuse-status
64
+ ```
65
+
66
+ The status command reports the config source, host, masked public key, capture policy, active-run state, config path, and last runtime error.
67
+
60
68
  ### Method 2: Environment variables
61
69
 
62
70
  Set these before starting Pi:
@@ -124,6 +132,7 @@ Fine-grained capture flags can also be persisted:
124
132
  ```
125
133
 
126
134
  > **Security**: Keep `~/.pi/agent/pi-langfuse/config.json` private. Never commit API keys to version control.
135
+ > When the extension writes this file itself, it creates the config directory with `0700` permissions and the file with `0600` permissions where the host filesystem supports POSIX modes.
127
136
 
128
137
  ## Verify the Extension
129
138
 
@@ -135,6 +144,14 @@ pi list
135
144
 
136
145
  `pi-langfuse` should appear in the installed package list.
137
146
 
147
+ To verify the Langfuse host and API keys from inside Pi, run:
148
+
149
+ ```text
150
+ /langfuse-test
151
+ ```
152
+
153
+ This command makes a timeout-bounded authenticated request to Langfuse and, if it succeeds, sends a small test trace.
154
+
138
155
  ## What Appears in Langfuse
139
156
 
140
157
  - Each Pi session gets its own Langfuse session ID.
@@ -202,6 +219,7 @@ The extension must not upload raw absolute local paths, credentialed remotes, to
202
219
  ### No traces appearing?
203
220
 
204
221
  - Verify the API keys and run `/langfuse-setup` again if needed.
222
+ - Run `/langfuse-status` to confirm the loaded host, config source, privacy mode, and last runtime error.
205
223
  - Confirm the Langfuse project is active and accepts writes.
206
224
  - Confirm the keys have write permission.
207
225
  - Look for `📊 Langfuse:` log messages in Pi output.
package/README_CN.md CHANGED
@@ -57,6 +57,14 @@ Langfuse API 密钥可在 **Langfuse Cloud** -> **Settings** -> **API Keys** 中
57
57
  /langfuse-setup
58
58
  ```
59
59
 
60
+ 如需查看当前配置状态且不泄漏密钥:
61
+
62
+ ```text
63
+ /langfuse-status
64
+ ```
65
+
66
+ 状态命令会显示配置来源、主机地址、脱敏后的公钥、采集策略、是否有活跃运行、配置文件路径,以及最近一次运行时错误。
67
+
60
68
  ### 方式 2:环境变量
61
69
 
62
70
  在启动 Pi 前设置:
@@ -124,6 +132,7 @@ export LANGFUSE_CAPTURE_CWD=false
124
132
  ```
125
133
 
126
134
  > **安全提醒**:`~/.pi/agent/pi-langfuse/config.json` 包含敏感信息,不应提交到版本控制。
135
+ > 扩展自行写入该文件时,会在支持 POSIX 权限的文件系统上使用 `0700` 创建配置目录,并使用 `0600` 写入配置文件。
127
136
 
128
137
  ## 验证扩展是否已加载
129
138
 
@@ -135,6 +144,14 @@ pi list
135
144
 
136
145
  已安装包列表中应出现 `pi-langfuse`。
137
146
 
147
+ 如需在 Pi 内验证 Langfuse 主机地址和 API key:
148
+
149
+ ```text
150
+ /langfuse-test
151
+ ```
152
+
153
+ 该命令会先发起一次带超时的认证请求;认证通过后,再发送一条小的测试 trace。
154
+
138
155
  ## 在 Langfuse 中会看到什么
139
156
 
140
157
  - 每个 Pi 会话对应一个独立的 Langfuse session ID。
@@ -155,6 +172,7 @@ pi list
155
172
  ### 没有看到 trace
156
173
 
157
174
  - 先检查 API 密钥是否正确,必要时重新执行 `/langfuse-setup`。
175
+ - 执行 `/langfuse-status`,确认当前加载的主机、配置来源、隐私模式和最近一次运行时错误。
158
176
  - 确认 Langfuse 项目处于可写状态。
159
177
  - 确认密钥具备写权限。
160
178
  - 在 Pi 输出中查找 `📊 Langfuse:` 日志。
package/index.ts CHANGED
@@ -13,7 +13,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { state, resetRunState, runWithSession, setCurrentSession } from "./src/state.js";
14
14
  import { ensureConfig, promptForConfig, loadConfig } from "./src/config.js";
15
15
  import { shutdownRuntime } from "./src/langfuse.js";
16
- import { handleLangfusePrivacyCommand, handleLangfuseTestCommand } from "./src/commands.js";
16
+ import { handleLangfusePrivacyCommand, handleLangfuseStatusCommand, handleLangfuseTestCommand } from "./src/commands.js";
17
17
  import { getMessageFromEvent, extractAssistantOutput, getCapturePolicy } from "./src/utils.js";
18
18
  import { applyCapturePolicy } from "./src/capture-policy.js";
19
19
  import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
@@ -60,6 +60,13 @@ export default async function (pi: ExtensionAPI) {
60
60
  },
61
61
  });
62
62
 
63
+ pi.registerCommand("langfuse-status", {
64
+ description: "Show Langfuse configuration and runtime status",
65
+ handler: async (args, ctx) => {
66
+ await handleLangfuseStatusCommand(String(args ?? ""), ctx);
67
+ },
68
+ });
69
+
63
70
  pi.registerCommand("langfuse-privacy", {
64
71
  description: "View or set Langfuse telemetry privacy preset",
65
72
  handler: async (args, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/commands.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
3
  import { CONFIG_PATH } from "./constants.js";
4
- import { loadConfig, saveConfig, ensureConfig } from "./config.js";
4
+ import {
5
+ loadConfig,
6
+ loadConfigFromEnv,
7
+ loadConfigFromFile,
8
+ sanitizeConfigForLog,
9
+ saveConfig,
10
+ ensureConfig,
11
+ } from "./config.js";
5
12
  import { createCapturePolicy, type PrivacyPreset, type CapturePolicy } from "./capture-policy.js";
6
- import { getRuntime, forceShutdownRuntime as shutdownLangfuseRuntime } from "./langfuse.js";
13
+ import { getRuntime, getLastRuntimeError, forceShutdownRuntime as shutdownLangfuseRuntime } from "./langfuse.js";
7
14
  import { state } from "./state.js";
8
- import type { LangfuseRuntime } from "./types.js";
15
+ import type { Config, LangfuseRuntime } from "./types.js";
9
16
 
10
17
  const PRIVACY_PRESETS = ["metadata-only", "prompts-only", "conversations", "full-debug"] as const;
11
18
 
@@ -21,6 +28,13 @@ interface CommandDeps {
21
28
  configPath?: string;
22
29
  getRuntime?: () => Promise<LangfuseRuntime>;
23
30
  forceShutdownRuntime?: () => Promise<void>;
31
+ env?: Record<string, string | undefined>;
32
+ checkConnectivity?: (config: Config) => Promise<ConnectivityResult>;
33
+ }
34
+
35
+ interface ConnectivityResult {
36
+ ok: boolean;
37
+ message: string;
24
38
  }
25
39
 
26
40
  function notify(ctx: CommandContextLike, message: string, level: "info" | "warning" | "error" = "info") {
@@ -128,6 +142,10 @@ function describePolicy(policy: CapturePolicy) {
128
142
  ].join("\n");
129
143
  }
130
144
 
145
+ function flag(value: boolean): "on" | "off" {
146
+ return value ? "on" : "off";
147
+ }
148
+
131
149
  function readPersistedConfig(path: string) {
132
150
  if (!existsSync(path)) {
133
151
  return {};
@@ -148,6 +166,108 @@ function hasActiveAgentObservation() {
148
166
  return false;
149
167
  }
150
168
 
169
+ function configSource(env: Record<string, string | undefined>, configPath: string): string {
170
+ const fileConfig = loadConfigFromFile(configPath, env);
171
+ const envConfig = loadConfigFromEnv(env);
172
+ if (fileConfig && envConfig) {
173
+ return "config file (env capture flags may override saved privacy)";
174
+ }
175
+ if (fileConfig) {
176
+ return "config file";
177
+ }
178
+ if (envConfig) {
179
+ return "environment variables";
180
+ }
181
+ return "none";
182
+ }
183
+
184
+ function lastErrorSummary() {
185
+ const lastError = getLastRuntimeError();
186
+ if (!lastError) {
187
+ return "none";
188
+ }
189
+ return `${lastError.scope}: ${lastError.message} (${lastError.timestamp.toISOString()})`;
190
+ }
191
+
192
+ function formatStatus(configPath: string, env: Record<string, string | undefined>) {
193
+ const config = loadConfig(env, configPath);
194
+ if (!config) {
195
+ return [
196
+ "pi-langfuse status:",
197
+ "State: not configured",
198
+ `Config file: ${configPath}`,
199
+ "Action: run /langfuse-setup or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY",
200
+ `Last error: ${lastErrorSummary()}`,
201
+ ].join("\n");
202
+ }
203
+
204
+ const safeConfig = sanitizeConfigForLog(config);
205
+ const policy = config.capturePolicy ?? createCapturePolicy(env);
206
+ return [
207
+ "pi-langfuse status:",
208
+ "State: configured",
209
+ `Source: ${configSource(env, configPath)}`,
210
+ `Host: ${safeConfig?.host ?? config.host}`,
211
+ `Public key: ${safeConfig?.publicKey ?? "[REDACTED_SECRET]"}`,
212
+ `Config file: ${configPath}`,
213
+ `Privacy preset: ${inferPreset(policy)}`,
214
+ "Capture:",
215
+ ` inputs: ${flag(policy.captureInputs)}`,
216
+ ` outputs: ${flag(policy.captureOutputs)}`,
217
+ ` tool IO: ${flag(policy.captureToolIo)}`,
218
+ ` system prompt: ${flag(policy.captureSystemPrompt)}`,
219
+ ` cwd: ${flag(policy.captureCwd)}`,
220
+ `Active run: ${hasActiveAgentObservation() ? "yes" : "no"}`,
221
+ `Last error: ${lastErrorSummary()}`,
222
+ ].join("\n");
223
+ }
224
+
225
+ async function checkLangfuseConnectivity(config: Config): Promise<ConnectivityResult> {
226
+ const host = config.host.replace(/\/+$/, "");
227
+ const auth = Buffer.from(`${config.publicKey}:${config.secretKey}`).toString("base64");
228
+
229
+ try {
230
+ const response = await fetch(`${host}/api/public/projects`, {
231
+ headers: {
232
+ Authorization: `Basic ${auth}`,
233
+ },
234
+ signal: AbortSignal.timeout(10_000),
235
+ });
236
+
237
+ if (response.ok) {
238
+ return { ok: true, message: `Connected to ${config.host}` };
239
+ }
240
+
241
+ return {
242
+ ok: false,
243
+ message: `${config.host} returned ${response.status} ${response.statusText}`.trim(),
244
+ };
245
+ } catch (error) {
246
+ return {
247
+ ok: false,
248
+ message: error instanceof Error ? error.message : String(error),
249
+ };
250
+ }
251
+ }
252
+
253
+ export async function handleLangfuseStatusCommand(
254
+ args: string,
255
+ ctx: CommandContextLike,
256
+ deps: CommandDeps = {},
257
+ ): Promise<boolean> {
258
+ const parsed = parseCommandArgs(args);
259
+ const unexpected = parsed.malformed[0] ?? parsed.positional[0] ?? Object.keys(parsed.values)[0];
260
+ if (unexpected) {
261
+ notify(ctx, `Unexpected argument '${unexpected}'. Usage: /langfuse-status`, "warning");
262
+ return false;
263
+ }
264
+
265
+ const env = deps.env ?? process.env;
266
+ const configPath = deps.configPath ?? CONFIG_PATH;
267
+ notify(ctx, formatStatus(configPath, env));
268
+ return true;
269
+ }
270
+
151
271
  function savePrivacyPreset(
152
272
  requestedPreset: PrivacyPreset,
153
273
  ctx: CommandContextLike,
@@ -227,10 +347,17 @@ export async function handleLangfusePrivacyCommand(
227
347
  }
228
348
 
229
349
  export async function handleLangfuseTestCommand(
230
- _args: string,
350
+ args: string,
231
351
  ctx: CommandContextLike,
232
352
  deps: CommandDeps = {},
233
353
  ): Promise<boolean> {
354
+ const parsed = parseCommandArgs(args);
355
+ const unexpected = parsed.malformed[0] ?? parsed.positional[0] ?? Object.keys(parsed.values)[0];
356
+ if (unexpected) {
357
+ notify(ctx, `Unexpected argument '${unexpected}'. Usage: /langfuse-test`, "warning");
358
+ return false;
359
+ }
360
+
234
361
  if (!state.config && !(await ensureConfig(ctx))) {
235
362
  notify(ctx, "Langfuse is not configured yet. Run /langfuse-setup first.", "warning");
236
363
  return false;
@@ -241,6 +368,18 @@ export async function handleLangfuseTestCommand(
241
368
  return false;
242
369
  }
243
370
 
371
+ const config = state.config;
372
+ if (!config) {
373
+ notify(ctx, "Langfuse is not configured yet. Run /langfuse-setup first.", "warning");
374
+ return false;
375
+ }
376
+
377
+ const connectivity = await (deps.checkConnectivity ?? checkLangfuseConnectivity)(config);
378
+ if (!connectivity.ok) {
379
+ notify(ctx, `Langfuse connectivity check failed: ${connectivity.message}`, "error");
380
+ return false;
381
+ }
382
+
244
383
  let runtimeInitialized = false;
245
384
  try {
246
385
  const rt = await (deps.getRuntime ?? getRuntime)();
@@ -270,7 +409,7 @@ export async function handleLangfuseTestCommand(
270
409
  return observation;
271
410
  },
272
411
  );
273
- notify(ctx, `Langfuse test succeeded. Test trace sent to ${state.config?.host}.`);
412
+ notify(ctx, `Langfuse test succeeded. ${connectivity.message}; test trace sent to ${config.host}.`);
274
413
  return true;
275
414
  } catch (error) {
276
415
  const message = error instanceof Error ? error.message : String(error);
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import type { Config } from "./types.js";
4
4
  import { CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
@@ -52,8 +52,33 @@ export function loadConfig(env: EnvLike = process.env as EnvLike, path = CONFIG_
52
52
  }
53
53
 
54
54
  export function saveConfig(config: Config, path = CONFIG_PATH) {
55
- mkdirSync(dirname(path), { recursive: true });
56
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
55
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
56
+ chmodSync(dirname(path), 0o700);
57
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
58
+ chmodSync(path, 0o600);
59
+ }
60
+
61
+ function maskPublicKey(value: string): string {
62
+ if (value.length <= 9) {
63
+ return "[REDACTED_SECRET]";
64
+ }
65
+ return `${value.slice(0, 6)}...${value.slice(-4)}`;
66
+ }
67
+
68
+ export function sanitizeConfigForLog(config: Pick<Config, "publicKey" | "secretKey" | "host"> | null): {
69
+ publicKey: string;
70
+ secretKey: string;
71
+ host: string;
72
+ } | null {
73
+ if (!config) {
74
+ return null;
75
+ }
76
+
77
+ return {
78
+ publicKey: maskPublicKey(config.publicKey),
79
+ secretKey: "[REDACTED_SECRET]",
80
+ host: config.host || DEFAULT_LANGFUSE_HOST,
81
+ };
57
82
  }
58
83
 
59
84
  async function collectConfigFromUI(ctx: any, reason: string): Promise<Config | null> {
@@ -184,7 +184,7 @@ export async function finishGenerationFromMessage(event: Record<string, unknown>
184
184
  model: model || undefined,
185
185
  modelParameters,
186
186
  usageDetails,
187
- costDetails,
187
+ ...(costDetails ? { costDetails } : {}),
188
188
  metadata: {
189
189
  ...generation.metadata,
190
190
  finishReason: message.finishReason ?? message.stopReason ?? event.finishReason,
@@ -232,7 +232,7 @@ export async function createFallbackGenerationFromTurn(event: Record<string, unk
232
232
  model: model || undefined,
233
233
  modelParameters,
234
234
  usageDetails,
235
- costDetails,
235
+ ...(costDetails ? { costDetails } : {}),
236
236
  metadata: captured.metadata,
237
237
  },
238
238
  asType: "generation",
package/src/langfuse.ts CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
 
5
5
  let runtime: LangfuseRuntime | null = null;
6
6
  const activeSessions = new Set<string>();
7
+ let lastRuntimeError: { scope: string; message: string; timestamp: Date } | null = null;
7
8
 
8
9
  type FallbackObservationType = "SPAN" | "GENERATION";
9
10
 
@@ -64,6 +65,18 @@ function debugLog(message: string) {
64
65
  }
65
66
  }
66
67
 
68
+ function rememberRuntimeError(scope: string, error: unknown) {
69
+ lastRuntimeError = {
70
+ scope,
71
+ message: error instanceof Error ? error.message : String(error),
72
+ timestamp: new Date(),
73
+ };
74
+ }
75
+
76
+ export function getLastRuntimeError(): { scope: string; message: string; timestamp: Date } | null {
77
+ return lastRuntimeError;
78
+ }
79
+
67
80
  async function withTimeout<T>(label: string, operation: Promise<T> | undefined): Promise<T | undefined> {
68
81
  if (!operation) {
69
82
  return undefined;
@@ -338,6 +351,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime) {
338
351
  const responseErrors = responseBody?.errors;
339
352
  const errors = Array.isArray(responseErrors) ? responseErrors : [];
340
353
  if (errors.length > 0) {
354
+ rememberRuntimeError("REST fallback ingestion", new Error(JSON.stringify(errors)));
341
355
  console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
342
356
  } else {
343
357
  debugLog(`📊 Langfuse: OTel trace ${trace.id} was not visible; wrote fallback trace via REST ingestion`);
@@ -371,30 +385,36 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
371
385
  attempted: false,
372
386
  };
373
387
 
374
- const spanProcessor = new LangfuseSpanProcessor({
375
- publicKey: state.config.publicKey,
376
- secretKey: state.config.secretKey,
377
- baseUrl: state.config.host,
378
- });
379
- const tracerProvider = new BasicTracerProvider({ spanProcessors: [spanProcessor] });
380
- tracing.setLangfuseTracerProvider(tracerProvider);
381
-
382
- runtime = {
383
- startObservation: ((name: string, body?: Record<string, unknown>, options?: { asType?: string }) => {
384
- const observation = (tracing as any).startObservation(name, body, options);
385
- return wrapObservation(observation, restFallback, name, body, options?.asType);
386
- }) as unknown as LangfuseRuntime["startObservation"],
387
- propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
388
- scoreClient: new LangfuseClient({
388
+ try {
389
+ const spanProcessor = new LangfuseSpanProcessor({
389
390
  publicKey: state.config.publicKey,
390
391
  secretKey: state.config.secretKey,
391
392
  baseUrl: state.config.host,
392
- }) as LangfuseScoreClient,
393
- spanProcessor,
394
- tracerProvider,
395
- clearTracerProvider: () => tracing.setLangfuseTracerProvider(null),
396
- restFallback,
397
- };
393
+ });
394
+ const tracerProvider = new BasicTracerProvider({ spanProcessors: [spanProcessor] });
395
+ tracing.setLangfuseTracerProvider(tracerProvider);
396
+
397
+ runtime = {
398
+ startObservation: ((name: string, body?: Record<string, unknown>, options?: { asType?: string }) => {
399
+ const observation = (tracing as any).startObservation(name, body, options);
400
+ return wrapObservation(observation, restFallback, name, body, options?.asType);
401
+ }) as unknown as LangfuseRuntime["startObservation"],
402
+ propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
403
+ scoreClient: new LangfuseClient({
404
+ publicKey: state.config.publicKey,
405
+ secretKey: state.config.secretKey,
406
+ baseUrl: state.config.host,
407
+ }) as LangfuseScoreClient,
408
+ spanProcessor,
409
+ tracerProvider,
410
+ clearTracerProvider: () => tracing.setLangfuseTracerProvider(null),
411
+ restFallback,
412
+ };
413
+ lastRuntimeError = null;
414
+ } catch (e) {
415
+ rememberRuntimeError("runtime init", e);
416
+ throw e;
417
+ }
398
418
  }
399
419
 
400
420
  return runtime as LangfuseRuntime;
@@ -416,6 +436,7 @@ function doShutdownRuntime(): Promise<void> {
416
436
  await withTimeout("Langfuse client shutdown", rt.scoreClient.shutdown?.());
417
437
  await withTimeout("OTel tracer shutdown", rt.tracerProvider?.shutdown?.());
418
438
  } catch (e) {
439
+ rememberRuntimeError("runtime shutdown", e);
419
440
  console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
420
441
  } finally {
421
442
  if (!runtime) {
@@ -472,6 +493,7 @@ export async function sendScore(name: string, value: number, options: { traceId?
472
493
  sessionId: options.traceId ? undefined : state.currentSessionId || undefined,
473
494
  });
474
495
  } catch (e) {
496
+ rememberRuntimeError(`score ${name}`, e);
475
497
  console.warn(`📊 Langfuse: Failed to send score ${name}`, e);
476
498
  }
477
499
  }
package/src/utils.ts CHANGED
@@ -400,6 +400,9 @@ export function extractCostDetails(messageOrEvent: Record<string, unknown>): Rec
400
400
  const input = Number(cost.input ?? cost.inputCost ?? 0);
401
401
  const output = Number(cost.output ?? cost.outputCost ?? 0);
402
402
  const total = Number(cost.total ?? cost.totalCost ?? input + output);
403
+ if (input === 0 && output === 0 && total === 0) {
404
+ return undefined;
405
+ }
403
406
 
404
407
  return { input, output, total };
405
408
  }