@narumitw/pi-langfuse 0.49.3 → 0.50.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
@@ -35,7 +35,7 @@ pi -e npm:@narumitw/pi-langfuse
35
35
  Try a local checkout:
36
36
 
37
37
  ```bash
38
- pi -e ./extensions/pi-langfuse
38
+ pi -e ./packages/pi-langfuse
39
39
  ```
40
40
 
41
41
  The Langfuse v4 SDK requires Node.js 20 or newer.
@@ -61,13 +61,14 @@ You can also create the file manually:
61
61
  "baseUrl": "https://us.cloud.langfuse.com",
62
62
  "environment": "development",
63
63
  "release": "local",
64
+ "userId": "your-user-id",
64
65
  "captureContent": true
65
66
  }
66
67
  ```
67
68
 
68
69
  `publicKey` and `secretKey` are required literal strings. Environment-variable and command interpolation are intentionally unsupported. `baseUrl` defaults to `https://us.cloud.langfuse.com`; regional and self-hosted HTTP or HTTPS endpoints are supported. Prefer HTTPS because HTTP sends Langfuse credentials and trace content without transport encryption.
69
70
 
70
- `environment` and `release` are optional Langfuse trace attributes. An environment must match Langfuse's contract: at most 40 lowercase letters, numbers, hyphens, or underscores, and it cannot start with `langfuse`. Set `captureContent` to `false` to trace timing, model, usage, cost, status, and bounded diagnostic metadata without sending prompts, provider-request snapshots, responses, or tool content.
71
+ `environment`, `release`, and `userId` are optional Langfuse trace attributes. An environment must match Langfuse's contract: at most 40 lowercase letters, numbers, hyphens, or underscores, and it cannot start with `langfuse`. `userId` populates the Langfuse user dimension, which is what the Sessions and Traces views group by; Langfuse accepts at most 200 characters, and leaving it unset reports no user. Set `captureContent` to `false` to trace timing, model, usage, cost, status, and bounded diagnostic metadata without sending prompts, provider-request snapshots, responses, or tool content.
71
72
 
72
73
  The extension automatically restricts an existing config file to mode `0600` and refuses to load credentials if that protection cannot be enforced. You can also set it explicitly:
73
74
 
@@ -75,7 +76,7 @@ The extension automatically restricts an existing config file to mode `0600` and
75
76
  chmod 600 ~/.pi/agent/pi-langfuse.json
76
77
  ```
77
78
 
78
- Restart Pi after changing credentials, endpoint, environment, release, or `captureContent`. The isolated OpenTelemetry tracer provider is initialized once per Pi process and selected only for Langfuse; it does not replace Pi's process-global provider or send Langfuse observations to another extension's exporter.
79
+ Restart Pi after changing credentials, endpoint, environment, release, or `captureContent`. Changes to `userId` apply to new sessions without restarting Pi. The isolated OpenTelemetry tracer provider is initialized once per Pi process and selected only for Langfuse; it does not replace Pi's process-global provider or send Langfuse observations to another extension's exporter.
79
80
 
80
81
  ## 🔭 What is traced
81
82
 
@@ -166,14 +167,14 @@ includes current tracing state and the manual configuration path.
166
167
 
167
168
  With content capture enabled, traces can contain user prompts, model responses, tool arguments, and tool results. These may include source code, file contents, shell output, or other sensitive project data. Review your Langfuse retention and access controls before enabling this extension.
168
169
 
169
- Git branch names, commit ids, working directory, session/leaf ids, model identity, usage/cost, aggregate counts, and allowlisted response-header values are metadata. They remain exported when `captureContent` is `false`; branch names and diagnostic header values can themselves contain operational details.
170
+ Git branch names, commit ids, working directory, session/leaf ids, the configured `userId`, model identity, usage/cost, aggregate counts, and allowlisted response-header values are metadata. They remain exported when `captureContent` is `false`; branch names and diagnostic header values can themselves contain operational details, and a `userId` may itself be personally identifying if you set it to an email address or a real name. Choose a pseudonymous value when that matters, or leave `userId` unset to export no user at all.
170
171
 
171
172
  The built-in mask specifically protects Langfuse credentials; it is not a general secret scanner. Set `"captureContent": false` in `pi-langfuse.json` when prompts, provider-request snapshots, responses, and tool content must remain local. Compaction summaries, tool partial results, opaque continuation signatures, authorization headers, cookies, and unapproved response headers are never exported in either mode.
172
173
 
173
174
  ## 🗂️ Package layout
174
175
 
175
176
  ```txt
176
- extensions/pi-langfuse/
177
+ packages/pi-langfuse/
177
178
  ├── src/
178
179
  │ ├── index.ts # Pi package entrypoint
179
180
  │ ├── langfuse.ts # Pi lifecycle integration and slash command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-langfuse",
3
- "version": "0.49.3",
3
+ "version": "0.50.0",
4
4
  "description": "Pi extension that traces LLM generations and tool activity to Langfuse.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,9 @@
23
23
  "./src/index.ts"
24
24
  ]
25
25
  },
26
+ "piExtension": {
27
+ "lifecycle": "stable"
28
+ },
26
29
  "scripts": {
27
30
  "check": "biome check . && npm run typecheck",
28
31
  "format": "biome check --write .",
@@ -32,15 +35,15 @@
32
35
  "@earendil-works/pi-coding-agent": "*"
33
36
  },
34
37
  "devDependencies": {
35
- "@biomejs/biome": "2.5.7",
36
- "@earendil-works/pi-coding-agent": "0.83.0",
37
- "@types/node": "26.1.2",
38
+ "@biomejs/biome": "2.5.8",
39
+ "@earendil-works/pi-coding-agent": "0.84.2",
40
+ "@types/node": "26.2.0",
38
41
  "typescript": "7.0.2"
39
42
  },
40
43
  "dependencies": {
41
44
  "@langfuse/otel": "^5.10.0",
42
45
  "@langfuse/tracing": "^5.10.0",
43
- "@narumitw/pi-tui-kit": "^0.49.1",
46
+ "@narumitw/pi-tui-kit": "^0.55.0",
44
47
  "@opentelemetry/api": "^1.9.1",
45
48
  "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
46
49
  "@opentelemetry/sdk-trace-base": "^2.10.0",
@@ -49,6 +52,6 @@
49
52
  "repository": {
50
53
  "type": "git",
51
54
  "url": "https://github.com/narumiruna/pi-extensions",
52
- "directory": "extensions/pi-langfuse"
55
+ "directory": "packages/pi-langfuse"
53
56
  }
54
57
  }
package/src/config.ts CHANGED
@@ -12,6 +12,7 @@ export interface LangfuseConfig {
12
12
  baseUrl: string;
13
13
  environment?: string;
14
14
  release?: string;
15
+ userId?: string;
15
16
  captureContent: boolean;
16
17
  }
17
18
 
@@ -61,6 +62,7 @@ async function writeLangfuseConfigNow(
61
62
  baseUrl: _baseUrl,
62
63
  environment: _environment,
63
64
  release: _release,
65
+ userId: _userId,
64
66
  captureContent: _captureContent,
65
67
  ...unknownFields
66
68
  } = currentDocument;
@@ -209,6 +211,11 @@ export function normalizeLangfuseConfig(
209
211
  }
210
212
  const release = optionalString(input.release, "release");
211
213
  if (!release.ok) return release;
214
+ const userId = optionalString(input.userId, "userId");
215
+ if (!userId.ok) return userId;
216
+ if (userId.value && userId.value.length > 200) {
217
+ return { ok: false, reason: "pi-langfuse.json userId must be at most 200 characters." };
218
+ }
212
219
 
213
220
  return {
214
221
  ok: true,
@@ -218,6 +225,7 @@ export function normalizeLangfuseConfig(
218
225
  baseUrl,
219
226
  ...(environment.value ? { environment: environment.value } : {}),
220
227
  ...(release.value ? { release: release.value } : {}),
228
+ ...(userId.value ? { userId: userId.value } : {}),
221
229
  captureContent: input.captureContent ?? true,
222
230
  },
223
231
  };
package/src/langfuse.ts CHANGED
@@ -201,6 +201,7 @@ export function createLangfuseExtension(
201
201
  activeConfig = result.config;
202
202
  recorder = new TraceRecorder(backend, {
203
203
  sessionId: ctx.sessionManager.getSessionId(),
204
+ ...(result.config.userId ? { userId: result.config.userId } : {}),
204
205
  cwd: ctx.cwd,
205
206
  mode: ctx.mode,
206
207
  captureContent: result.config.captureContent,
package/src/runtime.ts CHANGED
@@ -39,21 +39,23 @@ class ProductionObservation implements Observation {
39
39
  constructor(readonly native: LangfuseObservation) {}
40
40
 
41
41
  update(attributes: ObservationAttributes): Observation {
42
- this.native.updateOtelSpanAttributes(attributes as LangfuseObservationAttributes);
42
+ const { sessionId, userId, ...observationAttributes } = attributes;
43
+ this.native.updateOtelSpanAttributes(observationAttributes as LangfuseObservationAttributes);
44
+ applySessionId(this.native, sessionId);
45
+ applyUserId(this.native, userId);
43
46
  return this;
44
47
  }
45
48
 
46
49
  updateTrace(attributes: ObservationAttributes): Observation {
47
- const { input, output, metadata, name, sessionId, tags, version } = attributes;
50
+ const { input, output, metadata, name, sessionId, userId, tags, version } = attributes;
48
51
  if (input !== undefined || output !== undefined) {
49
52
  this.native.setTraceIO({ input, output });
50
53
  }
51
54
  if (name !== undefined) {
52
55
  this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_NAME, name);
53
56
  }
54
- if (sessionId !== undefined) {
55
- this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId);
56
- }
57
+ applySessionId(this.native, sessionId);
58
+ applyUserId(this.native, userId);
57
59
  if (tags !== undefined) {
58
60
  this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_TAGS, tags);
59
61
  }
@@ -86,11 +88,15 @@ class ProductionTraceBackend implements TraceBackend {
86
88
  attributes: ObservationAttributes,
87
89
  options: { asType: ObservationType; parent?: Observation },
88
90
  ): Observation {
91
+ const { sessionId, userId, ...observationAttributes } = attributes;
89
92
  const parent = options.parent;
90
- if (parent instanceof ProductionObservation) {
91
- return new ProductionObservation(startChild(parent.native, name, attributes, options.asType));
92
- }
93
- return new ProductionObservation(startRoot(name, attributes, options.asType));
93
+ const native =
94
+ parent instanceof ProductionObservation
95
+ ? startChild(parent.native, name, observationAttributes, options.asType)
96
+ : startRoot(name, observationAttributes, options.asType);
97
+ applySessionId(native, sessionId);
98
+ applyUserId(native, userId);
99
+ return new ProductionObservation(native);
94
100
  }
95
101
 
96
102
  async forceFlush(): Promise<void> {
@@ -177,6 +183,18 @@ const defaultFactories: RuntimeFactories = {
177
183
  selectProvider: setLangfuseTracerProvider,
178
184
  };
179
185
 
186
+ function applySessionId(observation: LangfuseObservation, sessionId: string | undefined): void {
187
+ if (sessionId !== undefined) {
188
+ observation.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId);
189
+ }
190
+ }
191
+
192
+ function applyUserId(observation: LangfuseObservation, userId: string | undefined): void {
193
+ if (userId !== undefined) {
194
+ observation.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId);
195
+ }
196
+ }
197
+
180
198
  function startRoot(
181
199
  name: string,
182
200
  attributes: ObservationAttributes,
package/src/tracing.ts CHANGED
@@ -40,6 +40,7 @@ export interface ObservationAttributes {
40
40
  version?: string;
41
41
  name?: string;
42
42
  sessionId?: string;
43
+ userId?: string;
43
44
  tags?: string[];
44
45
  }
45
46
 
@@ -63,6 +64,7 @@ export interface TraceBackend {
63
64
 
64
65
  interface RecorderContext {
65
66
  sessionId: string;
67
+ userId?: string;
66
68
  cwd: string;
67
69
  mode: string;
68
70
  captureContent: boolean;
@@ -234,6 +236,19 @@ export class TraceRecorder {
234
236
  private readonly context: RecorderContext,
235
237
  ) {}
236
238
 
239
+ private startObservation(
240
+ name: string,
241
+ attributes: ObservationAttributes,
242
+ options: { asType: ObservationType; parent?: Observation },
243
+ ): Observation {
244
+ const { sessionId, userId } = this.context;
245
+ return this.backend.start(
246
+ name,
247
+ { ...attributes, sessionId, ...(userId ? { userId } : {}) },
248
+ options,
249
+ );
250
+ }
251
+
237
252
  hasActiveTrace(): boolean {
238
253
  return this.root !== undefined;
239
254
  }
@@ -275,10 +290,11 @@ export class TraceRecorder {
275
290
  ? "git:detached"
276
291
  : undefined;
277
292
 
278
- this.root = this.backend.start("pi.agent", attributes, { asType: "agent" });
293
+ this.root = this.startObservation("pi.agent", attributes, { asType: "agent" });
279
294
  this.root.updateTrace?.({
280
295
  name: "pi.trace",
281
296
  sessionId: this.context.sessionId,
297
+ ...(this.context.userId ? { userId: this.context.userId } : {}),
282
298
  version: TRACE_SCHEMA_VERSION,
283
299
  input: traceInput,
284
300
  metadata,
@@ -296,7 +312,7 @@ export class TraceRecorder {
296
312
  this.lastAssistant = undefined;
297
313
  this.unresolvedToolErrors = 0;
298
314
  this.attemptIndex = index;
299
- this.attempt = this.backend.start(
315
+ this.attempt = this.startObservation(
300
316
  "pi.attempt",
301
317
  {
302
318
  metadata: {
@@ -325,7 +341,7 @@ export class TraceRecorder {
325
341
  if (this.turn) this.closeTurn("Interrupted by the next Pi turn.", "ERROR");
326
342
  this.counters.turns += 1;
327
343
  this.turnIndex = turnIndex;
328
- this.turn = this.backend.start(
344
+ this.turn = this.startObservation(
329
345
  "pi.turn",
330
346
  { metadata: { "pi.turn.index": turnIndex }, version: TRACE_SCHEMA_VERSION },
331
347
  { asType: "span", parent: this.attempt ?? this.root },
@@ -372,7 +388,7 @@ export class TraceRecorder {
372
388
  ...(input.model?.api ? { "pi.request.api": input.model.api } : {}),
373
389
  ...(input.thinkingLevel ? { "pi.request.thinking_level": input.thinkingLevel } : {}),
374
390
  };
375
- const observation = this.backend.start(
391
+ const observation = this.startObservation(
376
392
  "pi.llm",
377
393
  {
378
394
  ...(input.payload !== undefined ? { input: this.capture(input.payload) } : {}),
@@ -559,7 +575,7 @@ export class TraceRecorder {
559
575
  this.closeCompaction("Interrupted by another Pi compaction.");
560
576
  this.counters.compactions += 1;
561
577
  this.compaction = {
562
- observation: this.backend.start(
578
+ observation: this.startObservation(
563
579
  "pi.compaction",
564
580
  {
565
581
  metadata: {
@@ -711,7 +727,7 @@ export class TraceRecorder {
711
727
  }
712
728
 
713
729
  private startToolObservation(toolCallId: string, toolName: string, args?: unknown): Observation {
714
- return this.backend.start(
730
+ return this.startObservation(
715
731
  `pi.tool.${toolName}`,
716
732
  {
717
733
  ...(args !== undefined ? { input: this.capture(args) } : {}),