pi-langfuse 1.4.3 → 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/config.ts CHANGED
@@ -2,18 +2,25 @@ import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
2
2
  import type { Config } from "./types.js";
3
3
  import { CONFIG_DIR, CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
4
4
  import { state } from "./state.js";
5
- import { shutdownRuntime } from "./langfuse.js";
5
+ import { forceShutdownRuntime } from "./langfuse.js";
6
+ import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
6
7
 
7
- export function loadConfigFromFile(): Config | null {
8
- if (existsSync(CONFIG_PATH)) {
8
+ export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.env as EnvLike): Config | null {
9
+ if (existsSync(path)) {
9
10
  try {
10
- const content = readFileSync(CONFIG_PATH, "utf-8");
11
- const config = JSON.parse(content) as Config;
11
+ const content = readFileSync(path, "utf-8");
12
+ const config = JSON.parse(content) as Config & { capture?: EnvLike; privacyPreset?: string };
12
13
  if (config.publicKey && config.secretKey) {
14
+ const captureSource: EnvLike = {
15
+ ...(config.capture ?? {}),
16
+ ...(config.privacyPreset ? { LANGFUSE_PRIVACY_PRESET: config.privacyPreset } : {}),
17
+ ...env,
18
+ };
13
19
  return {
14
20
  publicKey: config.publicKey,
15
21
  secretKey: config.secretKey,
16
22
  host: config.host || DEFAULT_LANGFUSE_HOST,
23
+ capturePolicy: createCapturePolicy(captureSource),
17
24
  };
18
25
  }
19
26
  } catch (e) {
@@ -24,9 +31,9 @@ export function loadConfigFromFile(): Config | null {
24
31
  return null;
25
32
  }
26
33
 
27
- export function loadConfigFromEnv(): Config | null {
28
- const publicKey = process.env.LANGFUSE_PUBLIC_KEY || "";
29
- const secretKey = process.env.LANGFUSE_SECRET_KEY || "";
34
+ export function loadConfigFromEnv(env: EnvLike = process.env as EnvLike): Config | null {
35
+ const publicKey = env.LANGFUSE_PUBLIC_KEY || "";
36
+ const secretKey = env.LANGFUSE_SECRET_KEY || "";
30
37
  if (!publicKey || !secretKey) {
31
38
  return null;
32
39
  }
@@ -34,12 +41,13 @@ export function loadConfigFromEnv(): Config | null {
34
41
  return {
35
42
  publicKey,
36
43
  secretKey,
37
- host: process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
44
+ host: env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
45
+ capturePolicy: createCapturePolicy(env),
38
46
  };
39
47
  }
40
48
 
41
- export function loadConfig(): Config | null {
42
- return loadConfigFromFile() || loadConfigFromEnv();
49
+ export function loadConfig(env: EnvLike = process.env as EnvLike, path = CONFIG_PATH): Config | null {
50
+ return loadConfigFromFile(path, env) || loadConfigFromEnv(env);
43
51
  }
44
52
 
45
53
  export function saveConfig(config: Config) {
@@ -72,6 +80,7 @@ async function collectConfigFromUI(ctx: any, reason: string): Promise<Config | n
72
80
  publicKey,
73
81
  secretKey,
74
82
  host: hostInput || DEFAULT_LANGFUSE_HOST,
83
+ capturePolicy: createCapturePolicy(),
75
84
  };
76
85
  }
77
86
 
@@ -115,7 +124,7 @@ export async function ensureConfig(ctx: any): Promise<boolean> {
115
124
  export async function promptForConfig(ctx: any): Promise<boolean> {
116
125
  state.setupAttemptedThisSession = false;
117
126
  state.config = null;
118
- await shutdownRuntime();
127
+ await forceShutdownRuntime();
119
128
 
120
129
  const config = await collectConfigFromUI(ctx, "Manual setup requested");
121
130
  if (!config) {
@@ -1,8 +1,25 @@
1
1
  import { state, resetRunState, computeEvaluationScores } from "../state.js";
2
2
  import { getRuntime, sendScore } from "../langfuse.js";
3
3
  import { ensureConfig } from "../config.js";
4
- import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput } from "../utils.js";
4
+ import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput, getCapturePolicy } from "../utils.js";
5
5
  import { closeDanglingObservations } from "./tool.js";
6
+ import { applyCapturePolicy } from "../capture-policy.js";
7
+
8
+ function stringMetadata(metadata: Record<string, unknown> | undefined): Record<string, string> | undefined {
9
+ if (!metadata) {
10
+ return undefined;
11
+ }
12
+
13
+ const output: Record<string, string> = {};
14
+ for (const [key, value] of Object.entries(metadata)) {
15
+ if (typeof value === "string") {
16
+ output[key] = value;
17
+ } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
18
+ output[key] = String(value);
19
+ }
20
+ }
21
+ return Object.keys(output).length > 0 ? output : undefined;
22
+ }
6
23
 
7
24
  export function updateTraceIO(input?: unknown, output?: unknown) {
8
25
  const root = state.agentState?.root;
@@ -45,15 +62,28 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
45
62
  // Ignore if getSystemPrompt is not available or fails
46
63
  }
47
64
 
48
- const promptInput = shapePayload({
65
+ const rawPromptInput = shapePayload({
49
66
  prompt: event.prompt,
50
67
  images: event.images,
51
68
  context: event.context ?? event.attachments,
52
69
  });
70
+ const captured = applyCapturePolicy(
71
+ {
72
+ input: rawPromptInput,
73
+ metadata: {
74
+ cwd,
75
+ ...(state.currentModel ? { model: state.currentModel } : {}),
76
+ ...(state.currentProvider ? { provider: state.currentProvider } : {}),
77
+ sessionId: state.currentSessionId || undefined,
78
+ },
79
+ systemPrompt: systemPrompt ? truncate(String(systemPrompt), 20000) : undefined,
80
+ },
81
+ getCapturePolicy(),
82
+ );
53
83
 
54
84
  state.agentState = {
55
85
  cwd,
56
- promptInput,
86
+ promptInput: captured.input,
57
87
  generationSeq: 0,
58
88
  activeGenerations: new Map(),
59
89
  generationOrder: [],
@@ -65,32 +95,25 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
65
95
  {
66
96
  sessionId: state.currentSessionId ? truncate(state.currentSessionId, 200) : undefined,
67
97
  traceName: "pi-agent",
68
- metadata: {
69
- cwd: truncate(cwd, 200),
70
- ...(state.currentModel ? { model: truncate(state.currentModel, 200) } : {}),
71
- ...(state.currentProvider ? { provider: truncate(state.currentProvider, 200) } : {}),
72
- },
98
+ metadata: stringMetadata(captured.metadata),
73
99
  },
74
100
  () =>
75
101
  rt.startObservation(
76
102
  "pi-agent",
77
- {
78
- input: promptInput,
79
- metadata: {
80
- cwd,
81
- model: state.currentModel || undefined,
82
- provider: state.currentProvider || undefined,
83
- sessionId: state.currentSessionId || undefined,
84
- ...(systemPrompt ? { systemPrompt: truncate(String(systemPrompt), 20000) } : {}),
103
+ {
104
+ input: captured.input,
105
+ metadata: {
106
+ ...(captured.metadata ?? {}),
107
+ ...(captured.systemPrompt ? { systemPrompt: captured.systemPrompt } : {}),
108
+ },
85
109
  },
86
- },
87
110
  { asType: "agent" },
88
111
  ),
89
112
  );
90
113
 
91
114
  state.agentState.root = root;
92
115
  state.agentState.traceId = root.traceId;
93
- updateTraceIO(promptInput, undefined);
116
+ updateTraceIO(captured.input, undefined);
94
117
  } catch (e) {
95
118
  console.warn("📊 Langfuse: Failed to create agent observation", e);
96
119
  state.isTracingDisabled = true;
@@ -104,7 +127,21 @@ export async function finishAgentRun(event: Record<string, unknown> = {}) {
104
127
  }
105
128
 
106
129
  const lastAssistant = extractFinalAssistant(event.messages);
107
- const output = lastAssistant ? extractAssistantOutput(lastAssistant) : state.agentState.latestAssistantOutput;
130
+ const rawOutput = lastAssistant ? extractAssistantOutput(lastAssistant) : state.agentState.latestAssistantOutput;
131
+ const captured = applyCapturePolicy(
132
+ {
133
+ output: rawOutput,
134
+ metadata: {
135
+ cwd: state.agentState.cwd,
136
+ completed: true,
137
+ model: state.currentModel || undefined,
138
+ provider: state.currentProvider || undefined,
139
+ totalTools: state.toolCallCount,
140
+ ...computeEvaluationScores(),
141
+ },
142
+ },
143
+ getCapturePolicy(),
144
+ );
108
145
  const scores = computeEvaluationScores();
109
146
 
110
147
  closeDanglingObservations("Agent run ended before observation finalized");
@@ -112,18 +149,11 @@ export async function finishAgentRun(event: Record<string, unknown> = {}) {
112
149
  try {
113
150
  state.agentState.root
114
151
  .update({
115
- output,
116
- metadata: {
117
- cwd: state.agentState.cwd,
118
- completed: true,
119
- model: state.currentModel || undefined,
120
- provider: state.currentProvider || undefined,
121
- totalTools: state.toolCallCount,
122
- ...scores,
123
- },
152
+ output: captured.output,
153
+ metadata: captured.metadata,
124
154
  })
125
155
  .end();
126
- updateTraceIO(state.agentState.promptInput, output);
156
+ updateTraceIO(state.agentState.promptInput, captured.output);
127
157
 
128
158
  await sendScore("tool_call_count", scores.tool_call_count, { traceId: state.agentState.traceId });
129
159
  await sendScore("turn_count", scores.turn_count, { traceId: state.agentState.traceId });
@@ -1,5 +1,6 @@
1
1
  import { state } from "../state.js";
2
2
  import { getRuntime } from "../langfuse.js";
3
+ import { startChildObservation } from "../observation.js";
3
4
  import {
4
5
  getRequestKey,
5
6
  getProviderPayload,
@@ -9,8 +10,10 @@ import {
9
10
  extractAssistantOutput,
10
11
  extractUsage,
11
12
  extractCostDetails,
13
+ getCapturePolicy,
12
14
  } from "../utils.js";
13
15
  import type { GenerationState, ObservationUpdate } from "../types.js";
16
+ import { applyCapturePolicy } from "../capture-policy.js";
14
17
 
15
18
  export function getOpenGeneration(): GenerationState | undefined {
16
19
  if (state.isTracingDisabled || !state.agentState) {
@@ -44,33 +47,32 @@ export async function startGeneration(event: Record<string, unknown>) {
44
47
  url: event.url,
45
48
  method: event.method,
46
49
  }) as Record<string, unknown>;
50
+ const captured = applyCapturePolicy(
51
+ {
52
+ input: shapePayload(payload),
53
+ metadata,
54
+ },
55
+ getCapturePolicy(),
56
+ );
47
57
 
48
58
  const parent = state.agentState.activeTurn ?? state.agentState.root;
49
- const generation = parent.startObservation
50
- ? parent.startObservation(
51
- "llm-generation",
52
- {
53
- input: shapePayload(payload),
54
- model: model || undefined,
55
- metadata,
56
- },
57
- { asType: "generation" },
58
- )
59
- : (await getRuntime()).startObservation(
60
- "llm-generation",
61
- {
62
- input: shapePayload(payload),
63
- model: model || undefined,
64
- metadata,
65
- },
66
- { asType: "generation" },
67
- );
59
+ const generation = await startChildObservation({
60
+ parent,
61
+ runtime: getRuntime,
62
+ name: "llm-generation",
63
+ body: {
64
+ input: captured.input,
65
+ model: model || undefined,
66
+ metadata: captured.metadata,
67
+ },
68
+ asType: "generation",
69
+ });
68
70
 
69
71
  state.agentState.activeGenerations.set(key, {
70
72
  observation: generation,
71
73
  requestKey: key,
72
74
  ended: false,
73
- metadata,
75
+ metadata: captured.metadata ?? {},
74
76
  });
75
77
  state.agentState.generationOrder.push(key);
76
78
  } catch (e) {
@@ -84,7 +86,7 @@ export function updateGenerationMetadata(event: Record<string, unknown>) {
84
86
  }
85
87
 
86
88
  const key = getRequestKey(event, "");
87
- const metadata = extractResponseMetadata(event);
89
+ const metadata = applyCapturePolicy({ metadata: extractResponseMetadata(event) }, getCapturePolicy()).metadata ?? {};
88
90
  if (!key) {
89
91
  const generation = getOpenGeneration();
90
92
  if (generation) {
@@ -160,7 +162,9 @@ export async function finishGenerationFromMessage(event: Record<string, unknown>
160
162
  }
161
163
 
162
164
  const generation = getOpenGeneration();
163
- const output = extractAssistantOutput(message);
165
+ const rawOutput = extractAssistantOutput(message);
166
+ const captured = applyCapturePolicy({ output: rawOutput }, getCapturePolicy());
167
+ const output = captured.output;
164
168
  state.agentState.latestAssistantOutput = output;
165
169
 
166
170
  if (!generation) {
@@ -180,6 +184,7 @@ export async function finishGenerationFromMessage(event: Record<string, unknown>
180
184
  finishReason: message.finishReason ?? message.stopReason ?? event.finishReason,
181
185
  },
182
186
  };
187
+ update.metadata = applyCapturePolicy({ metadata: update.metadata }, getCapturePolicy()).metadata;
183
188
 
184
189
  try {
185
190
  generation.observation.update(update).end();
@@ -198,38 +203,32 @@ export async function createFallbackGenerationFromTurn(event: Record<string, unk
198
203
  const usageDetails = extractUsage({ ...event, message });
199
204
  const costDetails = extractCostDetails({ ...event, message });
200
205
  const model = String(message.model ?? event.model ?? state.currentModel ?? "");
206
+ const captured = applyCapturePolicy(
207
+ {
208
+ input: state.agentState.promptInput,
209
+ output: extractAssistantOutput(message),
210
+ metadata: {
211
+ provider: state.currentProvider || undefined,
212
+ sourceEvent: "turn_end",
213
+ },
214
+ },
215
+ getCapturePolicy(),
216
+ );
201
217
  const parent = state.agentState.activeTurn ?? state.agentState.root;
202
- const generation = parent.startObservation
203
- ? parent.startObservation(
204
- "llm-generation",
205
- {
206
- input: state.agentState.promptInput,
207
- output: extractAssistantOutput(message),
208
- model: model || undefined,
209
- usageDetails,
210
- costDetails,
211
- metadata: {
212
- provider: state.currentProvider || undefined,
213
- sourceEvent: "turn_end",
214
- },
215
- },
216
- { asType: "generation" },
217
- )
218
- : (await getRuntime()).startObservation(
219
- "llm-generation",
220
- {
221
- input: state.agentState.promptInput,
222
- output: extractAssistantOutput(message),
223
- model: model || undefined,
224
- usageDetails,
225
- costDetails,
226
- metadata: {
227
- provider: state.currentProvider || undefined,
228
- sourceEvent: "turn_end",
229
- },
230
- },
231
- { asType: "generation" },
232
- );
218
+ const generation = await startChildObservation({
219
+ parent,
220
+ runtime: getRuntime,
221
+ name: "llm-generation",
222
+ body: {
223
+ input: captured.input,
224
+ output: captured.output,
225
+ model: model || undefined,
226
+ usageDetails,
227
+ costDetails,
228
+ metadata: captured.metadata,
229
+ },
230
+ asType: "generation",
231
+ });
233
232
 
234
233
  generation.end();
235
234
  state.agentState.generationOrder.push("turn-end-fallback");
@@ -1,5 +1,6 @@
1
1
  import { state } from "../state.js";
2
2
  import { getRuntime, sendScore } from "../langfuse.js";
3
+ import { startChildObservation } from "../observation.js";
3
4
  import {
4
5
  getToolCallId,
5
6
  getToolName,
@@ -8,8 +9,11 @@ import {
8
9
  extractTextContent,
9
10
  truncate,
10
11
  estimatePayloadBytes,
12
+ getCapturePolicy,
11
13
  } from "../utils.js";
12
14
  import { MAX_TOOL_PAYLOAD_LENGTH } from "../constants.js";
15
+ import { applyCapturePolicy } from "../capture-policy.js";
16
+ import { redactString } from "../redaction.js";
13
17
 
14
18
  export async function startToolObservation(event: Record<string, unknown>) {
15
19
  if (state.isTracingDisabled || !state.agentState?.root) {
@@ -25,25 +29,25 @@ export async function startToolObservation(event: Record<string, unknown>) {
25
29
  const toolName = getToolName(event);
26
30
  const toolInput = getToolInput(event);
27
31
  const shapedInput = shapePayload(toolInput, { maxString: MAX_TOOL_PAYLOAD_LENGTH });
28
- const inputBytes = estimatePayloadBytes(shapedInput, MAX_TOOL_PAYLOAD_LENGTH);
32
+ const captured = applyCapturePolicy(
33
+ {
34
+ toolInput: shapedInput,
35
+ metadata: { toolName, toolCallId },
36
+ },
37
+ getCapturePolicy(),
38
+ );
39
+ const inputBytes = estimatePayloadBytes(captured.toolInput, MAX_TOOL_PAYLOAD_LENGTH);
29
40
  const parent = state.agentState.activeTurn ?? state.agentState.root;
30
- const tool = parent.startObservation
31
- ? parent.startObservation(
32
- toolName,
33
- {
34
- input: shapedInput,
35
- metadata: { toolName, toolCallId, inputBytes },
36
- },
37
- { asType: "tool" },
38
- )
39
- : (await getRuntime()).startObservation(
40
- toolName,
41
- {
42
- input: shapedInput,
43
- metadata: { toolName, toolCallId, inputBytes },
44
- },
45
- { asType: "tool" },
46
- );
41
+ const tool = await startChildObservation({
42
+ parent,
43
+ runtime: getRuntime,
44
+ name: toolName,
45
+ body: {
46
+ input: captured.toolInput,
47
+ metadata: { ...(captured.metadata ?? {}), inputBytes },
48
+ },
49
+ asType: "tool",
50
+ });
47
51
 
48
52
  state.toolCallCount++;
49
53
  state.agentState.activeTools.set(toolCallId, {
@@ -84,18 +88,27 @@ export async function finishToolObservation(event: Record<string, unknown>) {
84
88
 
85
89
  try {
86
90
  const shapedOutput = shapePayload(output, { maxString: MAX_TOOL_PAYLOAD_LENGTH });
87
- const outputBytes = estimatePayloadBytes(shapedOutput, MAX_TOOL_PAYLOAD_LENGTH);
91
+ const captured = applyCapturePolicy(
92
+ {
93
+ toolOutput: shapedOutput,
94
+ metadata: {
95
+ toolName: activeTool.toolName,
96
+ toolCallId,
97
+ isError,
98
+ },
99
+ },
100
+ getCapturePolicy(),
101
+ );
102
+ const outputBytes = estimatePayloadBytes(captured.toolOutput, MAX_TOOL_PAYLOAD_LENGTH);
88
103
  const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
89
104
 
90
105
  activeTool.observation
91
106
  .update({
92
- output: shapedOutput,
107
+ output: captured.toolOutput,
93
108
  level: isError ? "ERROR" : "DEFAULT",
94
- statusMessage: isError ? truncate(String(event.error ?? output), 1_000) : undefined,
109
+ statusMessage: isError ? redactString(truncate(String(event.error ?? output), 1_000)) : undefined,
95
110
  metadata: {
96
- toolName: activeTool.toolName,
97
- toolCallId,
98
- isError,
111
+ ...(captured.metadata ?? {}),
99
112
  durationMs,
100
113
  inputBytes: activeTool.inputBytes,
101
114
  outputBytes,
@@ -1,6 +1,8 @@
1
1
  import { state } from "../state.js";
2
2
  import { getRuntime } from "../langfuse.js";
3
- import { shapePayload } from "../utils.js";
3
+ import { startChildObservation } from "../observation.js";
4
+ import { shapePayload, getCapturePolicy } from "../utils.js";
5
+ import { applyCapturePolicy } from "../capture-policy.js";
4
6
 
5
7
  export async function startTurnObservation(event: Record<string, unknown>) {
6
8
  if (state.isTracingDisabled || !state.agentState?.root) {
@@ -15,23 +17,23 @@ export async function startTurnObservation(event: Record<string, unknown>) {
15
17
 
16
18
  try {
17
19
  const turnIndex = event.turnIndex ?? state.turnCount;
18
- const observation = state.agentState.root.startObservation
19
- ? state.agentState.root.startObservation(
20
- "turn",
21
- {
22
- input: shapePayload(event.context ?? event),
23
- metadata: { turnIndex },
24
- },
25
- { asType: "span" },
26
- )
27
- : (await getRuntime()).startObservation(
28
- "turn",
29
- {
30
- input: shapePayload(event.context ?? event),
31
- metadata: { turnIndex },
32
- },
33
- { asType: "span" },
34
- );
20
+ const captured = applyCapturePolicy(
21
+ {
22
+ input: shapePayload(event.context ?? event),
23
+ metadata: { turnIndex },
24
+ },
25
+ getCapturePolicy(),
26
+ );
27
+ const observation = await startChildObservation({
28
+ parent: state.agentState.root,
29
+ runtime: getRuntime,
30
+ name: "turn",
31
+ body: {
32
+ input: captured.input,
33
+ metadata: captured.metadata,
34
+ },
35
+ asType: "span",
36
+ });
35
37
 
36
38
  state.agentState.activeTurn = observation;
37
39
  } catch (e) {
@@ -39,7 +41,7 @@ export async function startTurnObservation(event: Record<string, unknown>) {
39
41
  }
40
42
  }
41
43
 
42
- export function finishTurnObservation(event?: Record<string, unknown>) {
44
+ export function finishTurnObservation(_event?: Record<string, unknown>) {
43
45
  if (state.isTracingDisabled || !state.agentState?.activeTurn) {
44
46
  return;
45
47
  }
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
 
@@ -294,6 +295,14 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
294
295
  throw new Error("Langfuse config is not set");
295
296
  }
296
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
+
297
306
  if (!runtime) {
298
307
  const [{ BasicTracerProvider }, { LangfuseSpanProcessor }, tracing, { LangfuseClient }] = await Promise.all([
299
308
  import("@opentelemetry/sdk-trace-base"),
@@ -337,23 +346,58 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
337
346
  return runtime as LangfuseRuntime;
338
347
  }
339
348
 
340
- export async function shutdownRuntime(): Promise<void> {
341
- if (!runtime) {
342
- return;
343
- }
349
+ function doShutdownRuntime(): Promise<void> {
350
+ return (async () => {
351
+ if (!runtime) {
352
+ return;
353
+ }
344
354
 
345
- try {
346
- await runtime.tracerProvider?.forceFlush?.();
347
- await fallbackToRestIngestion(runtime);
348
- await runtime.scoreClient.flush?.();
349
- await runtime.scoreClient.shutdown?.();
350
- await runtime.tracerProvider?.shutdown?.();
351
- } catch (e) {
352
- console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
353
- } finally {
354
- runtime.clearTracerProvider?.();
355
+ const rt = runtime;
355
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);
356
383
  }
384
+
385
+ // Still have active sessions — keep the runtime alive.
386
+ if (activeSessions.size > 0) {
387
+ return;
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();
357
401
  }
358
402
 
359
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
+ }