pi-langfuse 1.4.2 → 1.4.4

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/src/langfuse.ts CHANGED
@@ -3,6 +3,7 @@ import { state } from "./state.js";
3
3
  import { randomUUID } from "node:crypto";
4
4
 
5
5
  let runtime: LangfuseRuntime | null = null;
6
+ const activeSessions = new Set<string>();
6
7
 
7
8
  type FallbackObservationType = "SPAN" | "GENERATION";
8
9
 
@@ -42,7 +43,8 @@ interface RestFallbackStore {
42
43
  attempted: boolean;
43
44
  }
44
45
 
45
- const OTEL_VISIBILITY_DELAY_MS = 1_500;
46
+ const OTEL_VISIBILITY_TIMEOUT_MS = 1_500;
47
+ const OTEL_VISIBILITY_POLL_INTERVAL_MS = 200;
46
48
 
47
49
  function nowIso() {
48
50
  return new Date().toISOString();
@@ -190,6 +192,23 @@ async function traceExists(rt: LangfuseRuntime, traceId: string): Promise<boolea
190
192
  }
191
193
  }
192
194
 
195
+ async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
196
+ const deadline = Date.now() + OTEL_VISIBILITY_TIMEOUT_MS;
197
+
198
+ while (true) {
199
+ if (await traceExists(rt, traceId)) {
200
+ return true;
201
+ }
202
+
203
+ const remainingMs = deadline - Date.now();
204
+ if (remainingMs <= 0) {
205
+ return false;
206
+ }
207
+
208
+ await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs));
209
+ }
210
+ }
211
+
193
212
  function eventTimestamp(record: { endTime?: string; startTime?: string; timestamp?: string }) {
194
213
  return record.endTime ?? record.startTime ?? record.timestamp ?? nowIso();
195
214
  }
@@ -201,8 +220,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime) {
201
220
  }
202
221
  store.attempted = true;
203
222
 
204
- await delay(OTEL_VISIBILITY_DELAY_MS);
205
- if (await traceExists(rt, store.trace.id)) {
223
+ if (await waitForTraceVisibility(rt, store.trace.id)) {
206
224
  return;
207
225
  }
208
226
 
@@ -277,6 +295,14 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
277
295
  throw new Error("Langfuse config is not set");
278
296
  }
279
297
 
298
+ // Track the current session as a runtime consumer.
299
+ // Multiple sessions can share the same runtime; shutdown is deferred
300
+ // until the last session releases it.
301
+ const sessionId = state.currentSessionId;
302
+ if (sessionId) {
303
+ activeSessions.add(sessionId);
304
+ }
305
+
280
306
  if (!runtime) {
281
307
  const [{ BasicTracerProvider }, { LangfuseSpanProcessor }, tracing, { LangfuseClient }] = await Promise.all([
282
308
  import("@opentelemetry/sdk-trace-base"),
@@ -320,23 +346,58 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
320
346
  return runtime as LangfuseRuntime;
321
347
  }
322
348
 
323
- export async function shutdownRuntime(): Promise<void> {
324
- if (!runtime) {
325
- return;
326
- }
349
+ function doShutdownRuntime(): Promise<void> {
350
+ return (async () => {
351
+ if (!runtime) {
352
+ return;
353
+ }
327
354
 
328
- try {
329
- await runtime.tracerProvider?.forceFlush?.();
330
- await fallbackToRestIngestion(runtime);
331
- await runtime.scoreClient.flush?.();
332
- await runtime.scoreClient.shutdown?.();
333
- await runtime.tracerProvider?.shutdown?.();
334
- } catch (e) {
335
- console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
336
- } finally {
337
- runtime.clearTracerProvider?.();
355
+ const rt = runtime;
338
356
  runtime = null;
357
+
358
+ try {
359
+ await rt.tracerProvider?.forceFlush?.();
360
+ await fallbackToRestIngestion(rt);
361
+ await rt.scoreClient.flush?.();
362
+ await rt.scoreClient.shutdown?.();
363
+ await rt.tracerProvider?.shutdown?.();
364
+ } catch (e) {
365
+ console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
366
+ } finally {
367
+ if (!runtime) {
368
+ rt.clearTracerProvider?.();
369
+ }
370
+ }
371
+ })();
372
+ }
373
+
374
+ /**
375
+ * Release the current session's reference to the Langfuse runtime.
376
+ * Only actually shuts down the runtime when the last session releases it.
377
+ * Accepts an optional sessionId for use outside of withSession (e.g. deferred callbacks).
378
+ */
379
+ export async function shutdownRuntime(sessionId?: string): Promise<void> {
380
+ const sid = sessionId ?? state.currentSessionId;
381
+ if (sid) {
382
+ activeSessions.delete(sid);
383
+ }
384
+
385
+ // Still have active sessions — keep the runtime alive.
386
+ if (activeSessions.size > 0) {
387
+ return;
339
388
  }
389
+
390
+ await doShutdownRuntime();
391
+ }
392
+
393
+ /**
394
+ * Force-shutdown the Langfuse runtime regardless of active session references.
395
+ * Used when the user manually reconfigures (e.g. /langfuse-setup) and needs
396
+ * a fresh runtime with new credentials.
397
+ */
398
+ export async function forceShutdownRuntime(): Promise<void> {
399
+ activeSessions.clear();
400
+ await doShutdownRuntime();
340
401
  }
341
402
 
342
403
  export async function sendScore(name: string, value: number, options: { traceId?: string; observationId?: string } = {}) {
@@ -0,0 +1,21 @@
1
+ import type { LangfuseObservation, LangfuseRuntime, ObservationUpdate } from "./types.js";
2
+
3
+ export async function startChildObservation({
4
+ parent,
5
+ runtime,
6
+ name,
7
+ body,
8
+ asType,
9
+ }: {
10
+ parent: LangfuseObservation;
11
+ runtime: () => Promise<LangfuseRuntime>;
12
+ name: string;
13
+ body?: ObservationUpdate;
14
+ asType: "generation" | "tool" | "span";
15
+ }): Promise<LangfuseObservation> {
16
+ if (parent.startObservation) {
17
+ return parent.startObservation(name, body, { asType });
18
+ }
19
+
20
+ return (await runtime()).startObservation(name, body, { asType });
21
+ }
@@ -0,0 +1,115 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const REDACTED = "[REDACTED_SECRET]";
4
+
5
+ export interface RedactOptions {
6
+ maxDepth: number;
7
+ maxArrayItems: number;
8
+ maxObjectKeys: number;
9
+ maxStringLength: number;
10
+ }
11
+
12
+ const DEFAULT_OPTIONS: RedactOptions = {
13
+ maxDepth: 6,
14
+ maxArrayItems: 50,
15
+ maxObjectKeys: 80,
16
+ maxStringLength: 12_000,
17
+ };
18
+
19
+ const SECRET_ASSIGNMENT_RE =
20
+ /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASS|API[_-]?KEY|PRIVATE[_-]?KEY|AUTH|COOKIE)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
21
+ const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
22
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi;
23
+ const KNOWN_TOKEN_RE =
24
+ /\b(?:sk-(?:lf|ant|proj|live|test)[A-Za-z0-9_-]*|pk-lf-[A-Za-z0-9_-]+|gh[pousr]_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\b/g;
25
+ const ABSOLUTE_PATH_RE =
26
+ /(?:\/Users\/[^/\s]+|\/home\/[^/\s]+|\/private\/tmp|\/tmp|[A-Za-z]:\\Users\\[^\\\s]+)(?:[^\s"'`]*)/g;
27
+ const SENSITIVE_FIELD_RE =
28
+ /^(authorization|cookie|setcookie|xapikey|apikey|token|accesstoken|refreshtoken|secret|secretkey|password|passwd|privatekey)$/;
29
+
30
+ export function hashPath(path: string): string {
31
+ return `[PATH_HASH:${createHash("sha256").update(path).digest("hex").slice(0, 12)}]`;
32
+ }
33
+
34
+ function truncate(value: string, maxStringLength: number): string {
35
+ return value.length > maxStringLength ? `${value.slice(0, maxStringLength)}... [truncated]` : value;
36
+ }
37
+
38
+ export function redactString(value: string, options: Partial<RedactOptions> = {}): string {
39
+ const merged = { ...DEFAULT_OPTIONS, ...options };
40
+ const truncated = truncate(value, merged.maxStringLength);
41
+ return truncated
42
+ .replace(PRIVATE_KEY_RE, REDACTED)
43
+ .replace(BEARER_RE, REDACTED)
44
+ .replace(KNOWN_TOKEN_RE, REDACTED)
45
+ .replace(SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=${REDACTED}`)
46
+ .replace(ABSOLUTE_PATH_RE, (path: string) => {
47
+ const envSuffix = path.match(/([/\\]\.env(?:\.[A-Za-z0-9_-]+)?)$/)?.[1];
48
+ return `${hashPath(envSuffix ? path.slice(0, -envSuffix.length) : path)}${envSuffix ?? ""}`;
49
+ });
50
+ }
51
+
52
+ function visit(value: unknown, options: RedactOptions, depth: number, seen: WeakSet<object>): unknown {
53
+ if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") {
54
+ return value;
55
+ }
56
+
57
+ if (typeof value === "bigint") {
58
+ return value.toString();
59
+ }
60
+
61
+ if (typeof value === "string") {
62
+ return redactString(value, options);
63
+ }
64
+
65
+ if (typeof value === "function" || typeof value === "symbol") {
66
+ return `[${typeof value}]`;
67
+ }
68
+
69
+ if (depth <= 0) {
70
+ return `[max depth ${options.maxDepth} reached]`;
71
+ }
72
+
73
+ if (value instanceof Error) {
74
+ return {
75
+ name: redactString(value.name, options),
76
+ message: redactString(value.message, options),
77
+ stack: value.stack ? redactString(value.stack, options) : undefined,
78
+ };
79
+ }
80
+
81
+ if (typeof value !== "object") {
82
+ return redactString(String(value), options);
83
+ }
84
+
85
+ if (seen.has(value)) {
86
+ return "[circular]";
87
+ }
88
+ seen.add(value);
89
+
90
+ if (Array.isArray(value)) {
91
+ const output = value
92
+ .slice(0, options.maxArrayItems)
93
+ .map((item) => visit(item, options, depth - 1, seen));
94
+ if (value.length > options.maxArrayItems) {
95
+ output.push(`[${value.length - options.maxArrayItems} truncated items]`);
96
+ }
97
+ return output;
98
+ }
99
+
100
+ const entries = Object.entries(value as Record<string, unknown>);
101
+ const output: Record<string, unknown> = {};
102
+ for (const [key, item] of entries.slice(0, options.maxObjectKeys)) {
103
+ const normalizedKey = key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
104
+ output[key] = SENSITIVE_FIELD_RE.test(normalizedKey) ? REDACTED : visit(item, options, depth - 1, seen);
105
+ }
106
+ if (entries.length > options.maxObjectKeys) {
107
+ output.__truncatedKeys = entries.length - options.maxObjectKeys;
108
+ }
109
+ return output;
110
+ }
111
+
112
+ export function redactValue(value: unknown, options: Partial<RedactOptions> = {}): unknown {
113
+ const merged: RedactOptions = { ...DEFAULT_OPTIONS, ...options };
114
+ return visit(value, merged, merged.maxDepth, new WeakSet<object>());
115
+ }
package/src/state.ts CHANGED
@@ -8,6 +8,8 @@ export interface SessionRunState {
8
8
  toolCallCount: number;
9
9
  errorCount: number;
10
10
  turnCount: number;
11
+ tracingDisabled: boolean;
12
+ setupAttemptedThisSession: boolean;
11
13
  }
12
14
 
13
15
  const DEFAULT_SESSION_ID = "__pi_langfuse_default_session__";
@@ -23,6 +25,8 @@ function createSessionRunState(): SessionRunState {
23
25
  toolCallCount: 0,
24
26
  errorCount: 0,
25
27
  turnCount: 0,
28
+ tracingDisabled: false,
29
+ setupAttemptedThisSession: false,
26
30
  };
27
31
  }
28
32
 
@@ -57,7 +61,6 @@ export function runWithSession<T>(sessionId: string | undefined, fn: () => T): T
57
61
 
58
62
  export const state = {
59
63
  config: null as Config | null,
60
- setupAttemptedThisSession: false,
61
64
  sessionStates: new Map<string, SessionRunState>(),
62
65
 
63
66
  get currentSessionId() {
@@ -109,10 +112,30 @@ export const state = {
109
112
  set turnCount(turnCount: number) {
110
113
  getSessionRunState().turnCount = turnCount;
111
114
  },
115
+
116
+ get isTracingDisabled() {
117
+ return getSessionRunState().tracingDisabled;
118
+ },
119
+ set isTracingDisabled(disabled: boolean) {
120
+ getSessionRunState().tracingDisabled = disabled;
121
+ },
122
+
123
+ get setupAttemptedThisSession() {
124
+ return getSessionRunState().setupAttemptedThisSession;
125
+ },
126
+ set setupAttemptedThisSession(attempted: boolean) {
127
+ getSessionRunState().setupAttemptedThisSession = attempted;
128
+ },
112
129
  };
113
130
 
114
131
  export function resetRunState(sessionId = getActiveSessionId()) {
115
- state.sessionStates.set(normalizeSessionId(sessionId), createSessionRunState());
132
+ const normalizedSessionId = normalizeSessionId(sessionId);
133
+ const setupAttemptedThisSession =
134
+ state.sessionStates.get(normalizedSessionId)?.setupAttemptedThisSession ?? false;
135
+ state.sessionStates.set(normalizedSessionId, {
136
+ ...createSessionRunState(),
137
+ setupAttemptedThisSession,
138
+ });
116
139
  }
117
140
 
118
141
  export function clearAllSessionStates() {
package/src/types.ts CHANGED
@@ -1,7 +1,10 @@
1
+ import type { CapturePolicy } from "./capture-policy.js";
2
+
1
3
  export interface Config {
2
4
  publicKey: string;
3
5
  secretKey: string;
4
6
  host: string;
7
+ capturePolicy?: CapturePolicy;
5
8
  }
6
9
 
7
10
  export interface LangfuseObservation {
package/src/utils.ts CHANGED
@@ -2,9 +2,17 @@ import {
2
2
  MAX_ARRAY_ITEMS,
3
3
  MAX_DEPTH,
4
4
  MAX_OBJECT_KEYS,
5
+ MAX_PAYLOAD_NODES,
5
6
  MAX_STRING_LENGTH,
6
7
  MAX_TOOL_PAYLOAD_LENGTH,
7
8
  } from "./constants.js";
9
+ import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
10
+ import { redactValue } from "./redaction.js";
11
+ import { state } from "./state.js";
12
+
13
+ export function getCapturePolicy(): CapturePolicy {
14
+ return state.config?.capturePolicy ?? createCapturePolicy();
15
+ }
8
16
 
9
17
  export function truncate(value: string, maxLength = MAX_STRING_LENGTH): string {
10
18
  return value.length > maxLength ? `${value.slice(0, maxLength)}... [truncated]` : value;
@@ -23,11 +31,28 @@ export function tryParseJson(value: string): unknown {
23
31
  }
24
32
  }
25
33
 
26
- export function shapePayload(value: unknown, options: { maxString?: number; depth?: number } = {}): unknown {
34
+ const PAYLOAD_TOO_LARGE = "[payload too large]";
35
+
36
+ export function shapePayload(
37
+ value: unknown,
38
+ options: { maxString?: number; depth?: number; maxNodes?: number; redact?: boolean } = {},
39
+ ): unknown {
27
40
  const maxString = options.maxString ?? MAX_STRING_LENGTH;
28
41
  const depth = options.depth ?? MAX_DEPTH;
42
+ const maxNodes = options.maxNodes ?? MAX_PAYLOAD_NODES;
43
+ const budget = { exhausted: false, nodeCount: 0 };
29
44
 
30
45
  function visit(item: unknown, remainingDepth: number, seen: WeakSet<object>): unknown {
46
+ if (budget.exhausted) {
47
+ return PAYLOAD_TOO_LARGE;
48
+ }
49
+
50
+ budget.nodeCount++;
51
+ if (budget.nodeCount > maxNodes) {
52
+ budget.exhausted = true;
53
+ return PAYLOAD_TOO_LARGE;
54
+ }
55
+
31
56
  if (typeof item === "string") {
32
57
  const truncated = truncate(item, maxString);
33
58
  const parsed = tryParseJson(truncated);
@@ -59,7 +84,15 @@ export function shapePayload(value: unknown, options: { maxString?: number; dept
59
84
  }
60
85
 
61
86
  if (Array.isArray(item)) {
62
- return item.slice(0, MAX_ARRAY_ITEMS).map((entry) => visit(entry, remainingDepth - 1, seen));
87
+ const output: unknown[] = [];
88
+ const limit = Math.min(item.length, MAX_ARRAY_ITEMS);
89
+ for (let index = 0; index < limit; index++) {
90
+ output.push(visit(item[index], remainingDepth - 1, seen));
91
+ if (budget.exhausted) {
92
+ break;
93
+ }
94
+ }
95
+ return output;
63
96
  }
64
97
 
65
98
  if (item instanceof Error) {
@@ -77,8 +110,16 @@ export function shapePayload(value: unknown, options: { maxString?: number; dept
77
110
  seen.add(item);
78
111
 
79
112
  const output: Record<string, unknown> = {};
80
- for (const [key, entry] of Object.entries(item as Record<string, unknown>).slice(0, MAX_OBJECT_KEYS)) {
81
- output[key] = visit(entry, remainingDepth - 1, seen);
113
+ let keyCount = 0;
114
+ for (const key in item as Record<string, unknown>) {
115
+ if (!Object.hasOwn(item, key)) {
116
+ continue;
117
+ }
118
+ output[key] = visit((item as Record<string, unknown>)[key], remainingDepth - 1, seen);
119
+ keyCount++;
120
+ if (budget.exhausted || keyCount >= MAX_OBJECT_KEYS) {
121
+ break;
122
+ }
82
123
  }
83
124
  return output;
84
125
  }
@@ -86,7 +127,15 @@ export function shapePayload(value: unknown, options: { maxString?: number; dept
86
127
  return String(item);
87
128
  }
88
129
 
89
- return visit(value, depth, new WeakSet<object>());
130
+ const shaped = visit(value, depth, new WeakSet<object>());
131
+ return options.redact === false
132
+ ? shaped
133
+ : redactValue(shaped, {
134
+ maxDepth: depth,
135
+ maxStringLength: maxString,
136
+ maxArrayItems: MAX_ARRAY_ITEMS,
137
+ maxObjectKeys: MAX_OBJECT_KEYS,
138
+ });
90
139
  }
91
140
 
92
141
  export function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGTH): string {
@@ -0,0 +1,49 @@
1
+ declare module "@opentelemetry/sdk-trace-base" {
2
+ export class BasicTracerProvider {
3
+ constructor(options?: { spanProcessors?: unknown[] });
4
+ forceFlush?(): Promise<void>;
5
+ shutdown?(): Promise<void>;
6
+ }
7
+ }
8
+
9
+ declare module "@langfuse/otel" {
10
+ export class LangfuseSpanProcessor {
11
+ constructor(options: {
12
+ publicKey: string;
13
+ secretKey: string;
14
+ baseUrl: string;
15
+ });
16
+ forceFlush?(): Promise<void>;
17
+ shutdown?(): Promise<void>;
18
+ }
19
+ }
20
+
21
+ declare module "@langfuse/tracing" {
22
+ export function setLangfuseTracerProvider(provider: unknown): void;
23
+
24
+ export function startObservation(
25
+ name: string,
26
+ body?: Record<string, unknown>,
27
+ options?: { asType?: string },
28
+ ): unknown;
29
+
30
+ export function propagateAttributes<T>(
31
+ params: {
32
+ sessionId?: string;
33
+ traceName?: string;
34
+ metadata?: Record<string, string>;
35
+ tags?: string[];
36
+ },
37
+ fn: () => T,
38
+ ): T;
39
+ }
40
+
41
+ declare module "@langfuse/client" {
42
+ export class LangfuseClient {
43
+ constructor(options: {
44
+ publicKey: string;
45
+ secretKey: string;
46
+ baseUrl: string;
47
+ });
48
+ }
49
+ }