@narumitw/pi-langfuse 0.28.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ Run the interactive manager, then choose **Set up Langfuse for this Pi agent dir
48
48
  /langfuse
49
49
  ```
50
50
 
51
- The setup flow prompts for the secret key, public key, and base URL in the same order Langfuse presents them. Leave either key blank to preserve its existing value when updating a valid config. Leave the base URL blank to use `https://us.cloud.langfuse.com`. The file is saved atomically with mode `0600`.
51
+ The setup flow prompts for the secret key, public key, and base URL in the same order Langfuse presents them. Leave either key blank to preserve its existing value when updating a valid config. Leave the base URL blank to use `https://us.cloud.langfuse.com`. The first successful setup creates the file. Within one Pi process, updates run in invocation order, reread the latest valid private document, preserve unknown fields, and save atomically with mode `0600`. Malformed JSON or invalid recognized fields block setup and update writes until repaired; errors redact entered and stored credentials. A failed save leaves the current session and prior file unchanged.
52
52
 
53
53
  Configuration belongs to the displayed Pi agent directory, not just the current conversation. Restart each running Pi process after saving; the new connection applies to subsequent sessions in that process. `/reload` is not sufficient because the isolated Langfuse runtime is initialized once per process. In print or JSON mode, edit the file manually because the interactive manager is unavailable.
54
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-langfuse",
3
- "version": "0.28.0",
3
+ "version": "0.35.0",
4
4
  "description": "Pi extension that traces LLM generations and tool activity to Langfuse.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,15 +30,15 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@langfuse/otel": "^5.9.1",
33
- "@langfuse/tracing": "^4.0.0",
33
+ "@langfuse/tracing": "^5.9.1",
34
34
  "@opentelemetry/api": "^1.9.1",
35
- "@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
36
- "@opentelemetry/sdk-trace-base": "^2.9.0",
37
- "@opentelemetry/sdk-trace-node": "^2.9.0"
35
+ "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
36
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
37
+ "@opentelemetry/sdk-trace-node": "^2.10.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@biomejs/biome": "2.5.3",
41
- "@earendil-works/pi-coding-agent": "0.80.10",
40
+ "@biomejs/biome": "2.5.5",
41
+ "@earendil-works/pi-coding-agent": "0.82.1",
42
42
  "@types/node": "26.1.1",
43
43
  "typescript": "7.0.2"
44
44
  },
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
3
  import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  const CONFIG_FILE_NAME = "pi-langfuse.json";
7
7
  export const DEFAULT_BASE_URL = "https://us.cloud.langfuse.com";
@@ -15,31 +15,66 @@ export interface LangfuseConfig {
15
15
  captureContent: boolean;
16
16
  }
17
17
 
18
+ export type LangfuseConfigPatch = Partial<LangfuseConfig>;
19
+
18
20
  export type LangfuseConfigResult =
19
21
  | { ok: true; config: LangfuseConfig; path: string; warnings: string[] }
20
22
  | { ok: false; path: string; warnings: string[]; reason: string };
21
23
 
22
24
  export function langfuseConfigPath(): string {
23
- return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), CONFIG_FILE_NAME);
25
+ return join(getAgentDir(), CONFIG_FILE_NAME);
26
+ }
27
+
28
+ const configSaveQueues = new Map<string, Promise<void>>();
29
+
30
+ export async function waitForLangfuseConfigWrites(path = langfuseConfigPath()): Promise<void> {
31
+ await configSaveQueues.get(path);
24
32
  }
25
33
 
26
- export async function writeLangfuseConfig(
27
- config: LangfuseConfig,
34
+ export function writeLangfuseConfig(
35
+ patch: LangfuseConfigPatch,
28
36
  path = langfuseConfigPath(),
29
37
  ): Promise<LangfuseConfig> {
30
- const normalized = normalizeLangfuseConfig(config);
38
+ const previous = configSaveQueues.get(path) ?? Promise.resolve();
39
+ const operation = previous.then(() => writeLangfuseConfigNow(patch, path));
40
+ const tail = operation.then(
41
+ () => undefined,
42
+ () => undefined,
43
+ );
44
+ configSaveQueues.set(path, tail);
45
+ void tail.then(() => {
46
+ if (configSaveQueues.get(path) === tail) configSaveQueues.delete(path);
47
+ });
48
+ return operation;
49
+ }
50
+
51
+ async function writeLangfuseConfigNow(
52
+ patch: LangfuseConfigPatch,
53
+ path: string,
54
+ ): Promise<LangfuseConfig> {
55
+ const currentDocument = await readLangfuseDocumentForUpdate(path);
56
+ const normalized = normalizeLangfuseConfig({ ...currentDocument, ...patch });
31
57
  if (!normalized.ok) throw new Error(normalized.reason);
58
+ const {
59
+ publicKey: _publicKey,
60
+ secretKey: _secretKey,
61
+ baseUrl: _baseUrl,
62
+ environment: _environment,
63
+ release: _release,
64
+ captureContent: _captureContent,
65
+ ...unknownFields
66
+ } = currentDocument;
67
+ const nextDocument = { ...unknownFields, ...normalized.config };
32
68
 
33
69
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
34
70
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
35
71
  try {
36
- await writeFile(tempPath, `${JSON.stringify(normalized.config, null, 2)}\n`, {
72
+ await writeFile(tempPath, `${JSON.stringify(nextDocument, null, 2)}\n`, {
37
73
  encoding: "utf8",
38
74
  mode: 0o600,
39
75
  });
40
76
  await chmod(tempPath, 0o600);
41
77
  await rename(tempPath, path);
42
- await chmod(path, 0o600);
43
78
  return normalized.config;
44
79
  } catch (error) {
45
80
  await rm(tempPath, { force: true }).catch(() => undefined);
@@ -47,9 +82,29 @@ export async function writeLangfuseConfig(
47
82
  }
48
83
  }
49
84
 
85
+ async function readLangfuseDocumentForUpdate(path: string): Promise<Record<string, unknown>> {
86
+ let parsed: unknown;
87
+ try {
88
+ parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
89
+ } catch (error) {
90
+ if (isNodeError(error) && error.code === "ENOENT") return {};
91
+ throw new Error(
92
+ `Cannot update ${CONFIG_FILE_NAME} because the existing file is malformed or unreadable; repair it first.`,
93
+ );
94
+ }
95
+ const normalized = normalizeLangfuseConfig(parsed);
96
+ if (!normalized.ok) {
97
+ throw new Error(
98
+ `Cannot update ${CONFIG_FILE_NAME} because the existing file has invalid recognized fields; repair it first.`,
99
+ );
100
+ }
101
+ return { ...(parsed as Record<string, unknown>) };
102
+ }
103
+
50
104
  export async function loadLangfuseConfig(
51
105
  path = langfuseConfigPath(),
52
106
  ): Promise<LangfuseConfigResult> {
107
+ await configSaveQueues.get(path);
53
108
  const warnings: string[] = [];
54
109
  const permissionFailure = await ensurePrivatePermissions(path, warnings);
55
110
  if (permissionFailure) {
@@ -61,9 +116,9 @@ export async function loadLangfuseConfig(
61
116
  };
62
117
  }
63
118
 
64
- let parsed: unknown;
119
+ let text: string;
65
120
  try {
66
- parsed = JSON.parse(await readFile(path, "utf8"));
121
+ text = await readFile(path, "utf8");
67
122
  } catch (error) {
68
123
  if (isNodeError(error) && error.code === "ENOENT") {
69
124
  return { ok: false, path, warnings, reason: `Configuration file not found: ${path}` };
@@ -76,6 +131,18 @@ export async function loadLangfuseConfig(
76
131
  };
77
132
  }
78
133
 
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = JSON.parse(text) as unknown;
137
+ } catch {
138
+ return {
139
+ ok: false,
140
+ path,
141
+ warnings,
142
+ reason: `Failed to parse ${path} as JSON; repair the file before retrying.`,
143
+ };
144
+ }
145
+
79
146
  const normalized = normalizeLangfuseConfig(parsed);
80
147
  if (!normalized.ok) return { ok: false, path, warnings, reason: normalized.reason };
81
148
  return { ok: true, config: normalized.config, path, warnings };
package/src/langfuse.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  import {
7
7
  DEFAULT_BASE_URL,
8
8
  type LangfuseConfig,
9
+ type LangfuseConfigPatch,
9
10
  type LangfuseConfigResult,
10
11
  loadLangfuseConfig,
11
12
  normalizeLangfuseConfig,
@@ -20,7 +21,7 @@ import {
20
21
 
21
22
  interface ExtensionDependencies {
22
23
  loadConfig(path?: string): Promise<LangfuseConfigResult>;
23
- writeConfig(config: LangfuseConfig, path?: string): Promise<LangfuseConfig>;
24
+ writeConfig(config: LangfuseConfigPatch, path?: string): Promise<unknown>;
24
25
  createBackend(config: LangfuseConfig): Promise<TraceBackend>;
25
26
  resolveGitMetadata(cwd: string): Promise<GitMetadata | undefined>;
26
27
  }
@@ -54,6 +55,7 @@ export function createLangfuseExtension(
54
55
  let hasStoredConfig = false;
55
56
  let configurationNotice: string | undefined;
56
57
  let sessionGeneration = 0;
58
+ let configWriteQueue = Promise.resolve();
57
59
  let nextAttemptReason: string | undefined;
58
60
 
59
61
  pi.registerCommand("langfuse", {
@@ -100,10 +102,19 @@ export function createLangfuseExtension(
100
102
  const loaded = await loadConfig(configPath);
101
103
  if (menuGeneration !== sessionGeneration) return;
102
104
  configPath = loaded.path;
103
- const next = await promptForConfig(ctx, loaded.ok ? loaded.config : undefined);
105
+ const next = await promptForConfig(
106
+ ctx,
107
+ loaded.ok ? loaded.config : undefined,
108
+ () => menuGeneration === sessionGeneration,
109
+ );
104
110
  if (!next || menuGeneration !== sessionGeneration) return;
105
111
  try {
106
- await writeConfig(next, loaded.path);
112
+ const write = configWriteQueue.then(() => writeConfig(next, loaded.path));
113
+ configWriteQueue = write.then(
114
+ () => undefined,
115
+ () => undefined,
116
+ );
117
+ await write;
107
118
  if (menuGeneration !== sessionGeneration) return;
108
119
  hasStoredConfig = true;
109
120
  configurationNotice =
@@ -126,7 +137,7 @@ export function createLangfuseExtension(
126
137
  });
127
138
 
128
139
  pi.on("session_start", async (_event, ctx) => {
129
- sessionGeneration += 1;
140
+ const generation = ++sessionGeneration;
130
141
  recorder = undefined;
131
142
  activeConfig = undefined;
132
143
  configPath = undefined;
@@ -135,7 +146,10 @@ export function createLangfuseExtension(
135
146
  configurationNotice = undefined;
136
147
  nextAttemptReason = undefined;
137
148
 
149
+ await configWriteQueue;
150
+ if (generation !== sessionGeneration) return;
138
151
  const result = await loadConfig();
152
+ if (generation !== sessionGeneration) return;
139
153
  configPath = result.path;
140
154
  for (const warning of result.warnings) ctx.ui.notify(warning, "warning");
141
155
  if (!result.ok) {
@@ -147,6 +161,10 @@ export function createLangfuseExtension(
147
161
  hasStoredConfig = true;
148
162
  try {
149
163
  const backend = await createBackend(result.config);
164
+ if (generation !== sessionGeneration) {
165
+ await backend.shutdown().catch(() => undefined);
166
+ return;
167
+ }
150
168
  activeConfig = result.config;
151
169
  recorder = new TraceRecorder(backend, {
152
170
  sessionId: ctx.sessionManager.getSessionId(),
@@ -155,6 +173,7 @@ export function createLangfuseExtension(
155
173
  captureContent: result.config.captureContent,
156
174
  });
157
175
  } catch (error) {
176
+ if (generation !== sessionGeneration) return;
158
177
  initializationError = `Langfuse tracing could not start: ${formatError(error, result.config)}`;
159
178
  ctx.ui.notify(initializationError, "warning");
160
179
  }
@@ -280,23 +299,26 @@ export function createLangfuseExtension(
280
299
  });
281
300
 
282
301
  pi.on("session_shutdown", async (event, ctx) => {
283
- sessionGeneration += 1;
302
+ const generation = ++sessionGeneration;
284
303
  const activeRecorder = recorder;
285
304
  const shutdownConfig = activeConfig;
305
+ const pendingConfigWrite = configWriteQueue;
306
+ const shutdownSnapshot = activeRecorder ? contextSnapshot(ctx) : undefined;
286
307
  recorder = undefined;
287
308
  activeConfig = undefined;
309
+ await pendingConfigWrite;
288
310
  if (!activeRecorder) return;
289
311
  try {
290
- const snapshot = contextSnapshot(ctx);
291
- if (event.reason === "quit") await activeRecorder.shutdown(snapshot);
312
+ if (event.reason === "quit") await activeRecorder.shutdown(shutdownSnapshot);
292
313
  else {
293
314
  activeRecorder.interrupt(
294
315
  `Pi session ended before settlement (${event.reason}).`,
295
- snapshot,
316
+ shutdownSnapshot,
296
317
  );
297
318
  await activeRecorder.flush();
298
319
  }
299
320
  } catch (error) {
321
+ if (generation !== sessionGeneration) return;
300
322
  ctx.ui.notify(
301
323
  `Langfuse shutdown export failed: ${formatError(error, shutdownConfig)}`,
302
324
  "error",
@@ -417,13 +439,16 @@ function normalizeGitBranch(value: string): string | undefined {
417
439
  async function promptForConfig(
418
440
  ctx: ExtensionCommandContext,
419
441
  current: LangfuseConfig | undefined,
420
- ): Promise<LangfuseConfig | undefined> {
442
+ isCurrent: () => boolean,
443
+ ): Promise<LangfuseConfigPatch | undefined> {
421
444
  const secretKey = await ctx.ui.input("Langfuse secret key (leave blank to keep existing):");
445
+ if (!isCurrent()) return undefined;
422
446
  if (secretKey === undefined) {
423
447
  ctx.ui.notify("Cancelled", "info");
424
448
  return undefined;
425
449
  }
426
450
  const publicKey = await ctx.ui.input("Langfuse public key (leave blank to keep existing):");
451
+ if (!isCurrent()) return undefined;
427
452
  if (publicKey === undefined) {
428
453
  ctx.ui.notify("Cancelled", "info");
429
454
  return undefined;
@@ -432,23 +457,23 @@ async function promptForConfig(
432
457
  `Langfuse base URL (leave blank for default ${DEFAULT_BASE_URL}):`,
433
458
  DEFAULT_BASE_URL,
434
459
  );
460
+ if (!isCurrent()) return undefined;
435
461
  if (baseUrl === undefined) {
436
462
  ctx.ui.notify("Cancelled", "info");
437
463
  return undefined;
438
464
  }
439
465
 
440
- const normalized = normalizeLangfuseConfig({
441
- ...current,
442
- publicKey: publicKey.trim() || current?.publicKey || "",
443
- secretKey: secretKey.trim() || current?.secretKey || "",
466
+ const patch: LangfuseConfigPatch = {
467
+ ...(publicKey.trim() ? { publicKey: publicKey.trim() } : {}),
468
+ ...(secretKey.trim() ? { secretKey: secretKey.trim() } : {}),
444
469
  baseUrl: baseUrl.trim() || DEFAULT_BASE_URL,
445
- captureContent: current?.captureContent ?? true,
446
- });
470
+ };
471
+ const normalized = normalizeLangfuseConfig({ ...current, ...patch });
447
472
  if (!normalized.ok) {
448
473
  ctx.ui.notify(`Invalid Langfuse config: ${normalized.reason}`, "error");
449
474
  return undefined;
450
475
  }
451
- return normalized.config;
476
+ return patch;
452
477
  }
453
478
 
454
479
  function formatMenuTitle(
@@ -524,7 +549,7 @@ function formatConfigError(result: Extract<LangfuseConfigResult, { ok: false }>)
524
549
  return `Langfuse tracing is disabled: ${result.reason}${setupHint}`;
525
550
  }
526
551
 
527
- function formatError(error: unknown, config?: LangfuseConfig): string {
552
+ function formatError(error: unknown, config?: LangfuseConfigPatch): string {
528
553
  let message = error instanceof Error ? error.message : String(error);
529
554
  const secrets = [config?.publicKey, config?.secretKey]
530
555
  .filter((secret): secret is string => Boolean(secret))
package/src/runtime.ts CHANGED
@@ -3,7 +3,7 @@ import { LangfuseSpanProcessor } from "@langfuse/otel";
3
3
  import {
4
4
  type LangfuseObservation,
5
5
  type LangfuseObservationAttributes,
6
- type LangfuseTraceAttributes,
6
+ LangfuseOtelSpanAttributes,
7
7
  setLangfuseTracerProvider,
8
8
  startObservation,
9
9
  } from "@langfuse/tracing";
@@ -44,7 +44,28 @@ class ProductionObservation implements Observation {
44
44
  }
45
45
 
46
46
  updateTrace(attributes: ObservationAttributes): Observation {
47
- this.native.updateTrace(attributes as LangfuseTraceAttributes);
47
+ const { input, output, metadata, name, sessionId, tags, version } = attributes;
48
+ if (input !== undefined || output !== undefined) {
49
+ this.native.setTraceIO({ input, output });
50
+ }
51
+ if (name !== undefined) {
52
+ this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_NAME, name);
53
+ }
54
+ if (sessionId !== undefined) {
55
+ this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId);
56
+ }
57
+ if (tags !== undefined) {
58
+ this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_TAGS, tags);
59
+ }
60
+ if (version !== undefined) {
61
+ this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.VERSION, version);
62
+ }
63
+ for (const [key, value] of Object.entries(metadata ?? {})) {
64
+ this.native.otelSpan.setAttribute(
65
+ `${LangfuseOtelSpanAttributes.TRACE_METADATA}.${key}`,
66
+ String(value),
67
+ );
68
+ }
48
69
  return this;
49
70
  }
50
71
 
@@ -67,9 +88,7 @@ class ProductionTraceBackend implements TraceBackend {
67
88
  ): Observation {
68
89
  const parent = options.parent;
69
90
  if (parent instanceof ProductionObservation) {
70
- return new ProductionObservation(
71
- startChild(parent.native, name, attributes, options.asType),
72
- );
91
+ return new ProductionObservation(startChild(parent.native, name, attributes, options.asType));
73
92
  }
74
93
  return new ProductionObservation(startRoot(name, attributes, options.asType));
75
94
  }
@@ -236,10 +255,7 @@ function maskSecretValue(
236
255
  return data.map((item) => maskSecretValue(item, secrets, active));
237
256
  }
238
257
  return Object.fromEntries(
239
- Object.entries(data).map(([key, value]) => [
240
- key,
241
- maskSecretValue(value, secrets, active),
242
- ]),
258
+ Object.entries(data).map(([key, value]) => [key, maskSecretValue(value, secrets, active)]),
243
259
  );
244
260
  } finally {
245
261
  active.delete(data);
package/src/tracing.ts CHANGED
@@ -243,8 +243,7 @@ export class TraceRecorder {
243
243
  }
244
244
 
245
245
  beginAgent(input: BeginAgentInput): void {
246
- if (this.root)
247
- this.closeActiveTrace("Interrupted by a new Pi run.", undefined, "interrupted");
246
+ if (this.root) this.closeActiveTrace("Interrupted by a new Pi run.", undefined, "interrupted");
248
247
 
249
248
  this.lastOutput = undefined;
250
249
  this.lastAssistant = undefined;