@narumitw/pi-langfuse 0.50.6 → 0.51.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.
@@ -0,0 +1,570 @@
1
+ import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { resolveGitMetadata } from "./git.js";
3
+ import { getLangfuseRuntimeInternal, type LangfuseRuntime } from "./runtime-core.js";
4
+ import {
5
+ type ContextSnapshot,
6
+ type GitMetadata,
7
+ sanitizeTraceValue,
8
+ TraceRecorder,
9
+ type TraceRecorderOptions,
10
+ } from "./tracing.js";
11
+
12
+ export interface PiLangfuseSessionOptions {
13
+ traceName?: string;
14
+ sessionId?: string;
15
+ userId?: string;
16
+ tags?: readonly string[];
17
+ metadata?: Readonly<Record<string, unknown>>;
18
+ captureContent?: boolean;
19
+ onTraceId?: (traceId: string) => void;
20
+ }
21
+
22
+ export interface PiLangfuseSession {
23
+ readonly extension: ExtensionFactory;
24
+ setRequestId(requestId?: string): void;
25
+ dispose(): Promise<void>;
26
+ }
27
+
28
+ interface ResolvedSession {
29
+ runtime: LangfuseRuntime;
30
+ options?: PiLangfuseSessionOptions;
31
+ releaseIfStale?(reason: string): Promise<void>;
32
+ }
33
+
34
+ interface PiLangfuseSessionControllerOptions {
35
+ resolveSession(ctx: ExtensionContext, isCurrent: () => boolean): Promise<ResolvedSession | undefined>;
36
+ resolveGitMetadata?(pi: ExtensionAPI, cwd: string): Promise<GitMetadata | undefined>;
37
+ onSessionStart?(): void;
38
+ onSessionShutdown?(): void;
39
+ onSessionReady?(session: ResolvedSession): void;
40
+ onSessionUnavailable?(): void;
41
+ onInitializationError?(error: unknown, ctx: ExtensionContext): void;
42
+ beforeSessionDispose?(): Promise<void>;
43
+ onShutdownError?(error: unknown, ctx: ExtensionContext): void;
44
+ flushOnReplacement?: boolean;
45
+ shutdownRuntimeOnQuit?: boolean;
46
+ }
47
+
48
+ type Registration = object;
49
+
50
+ type InitializationResult = { ok: true } | { ok: false; error: unknown };
51
+
52
+ interface PendingInitialization {
53
+ current: boolean;
54
+ reason?: string;
55
+ completion: Promise<InitializationResult>;
56
+ complete(result: InitializationResult): void;
57
+ }
58
+
59
+ interface PendingProviderGeneration {
60
+ startedAt: number;
61
+ payload: unknown;
62
+ model?: { provider: string; id: string; api: string };
63
+ thinkingLevel?: string;
64
+ responses: Array<{ status: number; headers: Record<string, string> }>;
65
+ }
66
+
67
+ interface ActiveBinding {
68
+ registration: Registration;
69
+ recorder: TraceRecorder;
70
+ runtime: LangfuseRuntime;
71
+ releaseRuntime: () => void;
72
+ }
73
+
74
+ export interface PiLangfuseSessionController extends PiLangfuseSession {
75
+ readonly active: boolean;
76
+ readonly runtime: LangfuseRuntime | undefined;
77
+ flush(): Promise<void>;
78
+ }
79
+
80
+ export function createPiLangfuseSession(
81
+ runtime: LangfuseRuntime,
82
+ options: PiLangfuseSessionOptions = {},
83
+ ): PiLangfuseSession {
84
+ const sessionOptions = snapshotSessionOptions(options);
85
+ return createPiLangfuseSessionController({
86
+ resolveSession: async () => ({ runtime, options: sessionOptions }),
87
+ });
88
+ }
89
+
90
+ export function createPiLangfuseSessionController(
91
+ options: PiLangfuseSessionControllerOptions,
92
+ ): PiLangfuseSessionController {
93
+ let binding: ActiveBinding | undefined;
94
+ let runtimeForShutdown: LangfuseRuntime | undefined;
95
+ let ownerRegistration: Registration | undefined;
96
+ let pendingInitialization: PendingInitialization | undefined;
97
+ const outstandingInitializations = new Set<PendingInitialization>();
98
+ let disposePromise: Promise<void> | undefined;
99
+ let disposed = false;
100
+ let sessionGeneration = 0;
101
+ let requestId: string | undefined;
102
+ let requestIdRevision = 0;
103
+ let nextAttemptReason: string | undefined;
104
+ let lastSnapshot: ContextSnapshot | undefined;
105
+ let pendingProviderGeneration: PendingProviderGeneration | undefined;
106
+
107
+ const controller: PiLangfuseSessionController = {
108
+ extension(pi) {
109
+ if (disposed) throw new Error("A disposed Pi Langfuse session controller cannot be bound.");
110
+ registerHooks(pi);
111
+ },
112
+ setRequestId(value) {
113
+ requestId = normalizeOptionalString(value);
114
+ requestIdRevision += 1;
115
+ },
116
+ dispose() {
117
+ if (disposePromise) return disposePromise;
118
+ disposed = true;
119
+ const initializations = invalidateAllInitializations("disposed");
120
+ ownerRegistration = undefined;
121
+ sessionGeneration += 1;
122
+ closeBinding(binding, "Pi Langfuse session controller was disposed.", lastSnapshot);
123
+ runtimeForShutdown = undefined;
124
+ disposePromise = joinInitializations(initializations);
125
+ return disposePromise;
126
+ },
127
+ get active() {
128
+ return binding !== undefined;
129
+ },
130
+ get runtime() {
131
+ return binding?.runtime;
132
+ },
133
+ async flush() {
134
+ const runtime = binding?.runtime;
135
+ if (!runtime) throw new Error("Langfuse tracing is not enabled for this session.");
136
+ await runtime.flush();
137
+ },
138
+ };
139
+
140
+ function closeBinding(target: ActiveBinding | undefined, statusMessage: string, snapshot?: ContextSnapshot): void {
141
+ if (!target) return;
142
+ const wasCurrent = binding === target;
143
+ if (wasCurrent) {
144
+ binding = undefined;
145
+ pendingProviderGeneration = undefined;
146
+ }
147
+ target.releaseRuntime();
148
+ target.recorder.dispose(statusMessage, snapshot);
149
+ if (wasCurrent) nextAttemptReason = undefined;
150
+ }
151
+
152
+ function invalidatePendingInitialization(reason: string): void {
153
+ if (!pendingInitialization) return;
154
+ pendingInitialization.current = false;
155
+ pendingInitialization.reason = reason;
156
+ pendingInitialization = undefined;
157
+ }
158
+
159
+ function invalidateAllInitializations(reason: string): PendingInitialization[] {
160
+ const initializations = [...outstandingInitializations];
161
+ for (const initialization of initializations) {
162
+ initialization.current = false;
163
+ initialization.reason = reason;
164
+ }
165
+ pendingInitialization = undefined;
166
+ return initializations;
167
+ }
168
+
169
+ function createPendingInitialization(): PendingInitialization {
170
+ let complete!: (result: InitializationResult) => void;
171
+ const completion = new Promise<InitializationResult>((resolve) => {
172
+ complete = resolve;
173
+ });
174
+ const initialization = { current: true, completion, complete };
175
+ outstandingInitializations.add(initialization);
176
+ return initialization;
177
+ }
178
+
179
+ async function joinInitializations(initializations: readonly PendingInitialization[]): Promise<void> {
180
+ const results = await Promise.all(initializations.map(({ completion }) => completion));
181
+ const errors = results.flatMap((result) => (result.ok ? [] : [result.error]));
182
+ if (errors.length === 1) throw errors[0];
183
+ if (errors.length > 1) throw new AggregateError(errors, "Pi Langfuse session initialization cleanup failed.");
184
+ }
185
+
186
+ async function initializeSession(
187
+ registration: Registration,
188
+ initialization: PendingInitialization,
189
+ ctx: ExtensionContext,
190
+ ): Promise<void> {
191
+ const generation = ++sessionGeneration;
192
+ closeBinding(binding, "Pi session was replaced before shutdown completed.", lastSnapshot);
193
+ lastSnapshot = contextSnapshot(ctx);
194
+ nextAttemptReason = undefined;
195
+ options.onSessionStart?.();
196
+
197
+ const isCurrent = () =>
198
+ !disposed && initialization.current && ownerRegistration === registration && generation === sessionGeneration;
199
+ let resolved: ResolvedSession | undefined;
200
+ try {
201
+ resolved = await options.resolveSession(ctx, isCurrent);
202
+ } catch (error) {
203
+ if (isCurrent()) options.onInitializationError?.(error, ctx);
204
+ return;
205
+ }
206
+ if (!isCurrent()) {
207
+ const reason = initialization.reason ?? "replaced";
208
+ if (
209
+ resolved &&
210
+ options.shutdownRuntimeOnQuit &&
211
+ reason !== "quit" &&
212
+ reason !== "disposed" &&
213
+ (!runtimeForShutdown || runtimeForShutdown.closed)
214
+ ) {
215
+ runtimeForShutdown = resolved.runtime;
216
+ }
217
+ await resolved?.releaseIfStale?.(reason);
218
+ return;
219
+ }
220
+ if (!resolved) {
221
+ options.onSessionUnavailable?.();
222
+ return;
223
+ }
224
+
225
+ try {
226
+ const internal = getLangfuseRuntimeInternal(resolved.runtime);
227
+ const recorderOptions = createRecorderOptions(ctx, resolved.options);
228
+ const recorder = new TraceRecorder(internal.backend, recorderOptions);
229
+ const nextBinding = {} as ActiveBinding;
230
+ const releaseRuntime = internal.registerSession((statusMessage) => {
231
+ closeBinding(nextBinding, statusMessage, lastSnapshot);
232
+ });
233
+ Object.assign(nextBinding, { registration, recorder, runtime: resolved.runtime, releaseRuntime });
234
+ if (!isCurrent()) {
235
+ releaseRuntime();
236
+ return;
237
+ }
238
+ binding = nextBinding;
239
+ if (options.shutdownRuntimeOnQuit) runtimeForShutdown = resolved.runtime;
240
+ options.onSessionReady?.(resolved);
241
+ } catch (error) {
242
+ if (isCurrent()) options.onInitializationError?.(error, ctx);
243
+ }
244
+ }
245
+
246
+ function registerHooks(pi: ExtensionAPI): void {
247
+ const registration: Registration = {};
248
+ const activeRecorder = () => activeRecorderFor(registration);
249
+
250
+ pi.on("session_start", async (_event, ctx) => {
251
+ if (disposed) return;
252
+ pendingProviderGeneration = undefined;
253
+ if (ownerRegistration && ownerRegistration !== registration) {
254
+ options.onInitializationError?.(
255
+ new Error("A Pi Langfuse session controller cannot manage multiple active Pi sessions."),
256
+ ctx,
257
+ );
258
+ return;
259
+ }
260
+
261
+ invalidatePendingInitialization("replaced");
262
+ ownerRegistration = registration;
263
+ const initialization = createPendingInitialization();
264
+ pendingInitialization = initialization;
265
+ let result: InitializationResult = { ok: true };
266
+ try {
267
+ await initializeSession(registration, initialization, ctx);
268
+ } catch (error) {
269
+ result = { ok: false, error };
270
+ throw error;
271
+ } finally {
272
+ initialization.complete(result);
273
+ outstandingInitializations.delete(initialization);
274
+ if (pendingInitialization === initialization) pendingInitialization = undefined;
275
+ }
276
+ });
277
+
278
+ pi.on("before_agent_start", async (event, ctx) => {
279
+ // Cache warming emits provider hooks without assistant lifecycle events. Unclaimed
280
+ // metadata is discarded at the next real run rather than classified by timing.
281
+ pendingProviderGeneration = undefined;
282
+ const active = ownerRegistration === registration ? binding : undefined;
283
+ if (!active || active.runtime.closed) return;
284
+ nextAttemptReason = undefined;
285
+ const git = await (options.resolveGitMetadata
286
+ ? options.resolveGitMetadata(pi, ctx.cwd)
287
+ : resolveGitMetadata((command, args, execOptions) => pi.exec(command, args, execOptions), ctx.cwd)
288
+ ).catch(() => undefined);
289
+ if (ownerRegistration !== registration || binding !== active || active.runtime.closed) return;
290
+ lastSnapshot = contextSnapshot(ctx);
291
+ startRootTrace((nextRequestId) => {
292
+ active.recorder.beginAgent({
293
+ prompt: event.prompt,
294
+ images: event.images,
295
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
296
+ git,
297
+ snapshot: lastSnapshot,
298
+ ...(nextRequestId ? { requestId: nextRequestId } : {}),
299
+ });
300
+ });
301
+ });
302
+
303
+ pi.on("agent_start", () => {
304
+ const recorder = activeRecorder();
305
+ if (!recorder) return;
306
+ recorder.beginAttempt(nextAttemptReason ? { reason: nextAttemptReason } : undefined);
307
+ nextAttemptReason = undefined;
308
+ });
309
+
310
+ pi.on("turn_start", (event, ctx) => {
311
+ const recorder = activeRecorder();
312
+ if (!recorder) return;
313
+ lastSnapshot = contextSnapshot(ctx);
314
+ ensureActiveRun(recorder, ctx, startRootTrace);
315
+ recorder.beginTurn(event.turnIndex);
316
+ });
317
+
318
+ pi.on("before_provider_request", (event, ctx) => {
319
+ if (!activeRecorder()) return;
320
+ lastSnapshot = contextSnapshot(ctx);
321
+ pendingProviderGeneration = {
322
+ startedAt: Date.now(),
323
+ payload: event.payload,
324
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
325
+ thinkingLevel: pi.getThinkingLevel(),
326
+ responses: [],
327
+ };
328
+ });
329
+
330
+ pi.on("after_provider_response", (event) => {
331
+ pendingProviderGeneration?.responses.push({ status: event.status, headers: event.headers });
332
+ });
333
+
334
+ pi.on("message_start", (event, ctx) => {
335
+ if (event.message.role === "assistant") claimPendingProviderGeneration(ctx, activeRecorder());
336
+ });
337
+
338
+ pi.on("message_update", (event, ctx) => {
339
+ if (!isRealOutputDelta(event.assistantMessageEvent)) return;
340
+ const recorder = activeRecorder();
341
+ claimPendingProviderGeneration(ctx, recorder);
342
+ recorder?.markGenerationFirstOutput();
343
+ });
344
+
345
+ pi.on("message_end", (event, ctx) => {
346
+ if (event.message.role !== "assistant") return;
347
+ const recorder = activeRecorder();
348
+ claimPendingProviderGeneration(ctx, recorder);
349
+ recorder?.markGenerationEnd();
350
+ });
351
+
352
+ pi.on("turn_end", (event, ctx) => {
353
+ const recorder = activeRecorder();
354
+ if (!recorder) return;
355
+ if (event.message.role === "assistant") claimPendingProviderGeneration(ctx, recorder);
356
+ lastSnapshot = contextSnapshot(ctx);
357
+ if (event.message.role === "assistant") recorder.finishAssistant(event.message);
358
+ recorder.finishTurn(event.turnIndex, {
359
+ message: event.message,
360
+ toolResultCount: event.toolResults.length,
361
+ });
362
+ });
363
+
364
+ pi.on("tool_execution_start", (event) => {
365
+ activeRecorder()?.beginTool(event.toolCallId, event.toolName, event.args);
366
+ });
367
+
368
+ pi.on("tool_execution_update", (event) => {
369
+ activeRecorder()?.recordToolProgress(event.toolCallId);
370
+ });
371
+
372
+ pi.on("tool_result", (event) => {
373
+ activeRecorder()?.recordToolInput(event.toolCallId, event.input);
374
+ });
375
+
376
+ pi.on("tool_execution_end", (event) => {
377
+ activeRecorder()?.finishTool(event.toolCallId, {
378
+ content: event.result.content,
379
+ details: event.result.details,
380
+ isError: event.isError,
381
+ });
382
+ });
383
+
384
+ pi.on("agent_end", (event) => {
385
+ const message = findLastAssistant(event.messages);
386
+ activeRecorder()?.finishAttempt(message);
387
+ });
388
+
389
+ pi.on("session_before_compact", (event) => {
390
+ activeRecorder()?.beginCompaction({
391
+ reason: event.reason,
392
+ willRetry: event.willRetry,
393
+ tokensBefore: event.preparation.tokensBefore,
394
+ messagesToSummarize: event.preparation.messagesToSummarize.length,
395
+ turnPrefixMessages: event.preparation.turnPrefixMessages.length,
396
+ branchEntries: event.branchEntries.length,
397
+ isSplitTurn: event.preparation.isSplitTurn,
398
+ });
399
+ });
400
+
401
+ pi.on("session_compact", (event) => {
402
+ const recorder = activeRecorder();
403
+ const entry = event.compactionEntry as typeof event.compactionEntry & {
404
+ usage?: Parameters<TraceRecorder["finishCompaction"]>[0]["usage"];
405
+ };
406
+ recorder?.finishCompaction({
407
+ reason: event.reason,
408
+ willRetry: event.willRetry,
409
+ fromExtension: event.fromExtension,
410
+ tokensBefore: entry.tokensBefore,
411
+ details: entry.details,
412
+ usage: entry.usage,
413
+ });
414
+ if (event.willRetry && recorder?.hasActiveTrace()) nextAttemptReason = "post_compaction";
415
+ });
416
+
417
+ pi.on("agent_settled", (_event, ctx) => {
418
+ const recorder = activeRecorder();
419
+ if (!recorder) return;
420
+ lastSnapshot = contextSnapshot(ctx);
421
+ recorder.settle(lastSnapshot);
422
+ pendingProviderGeneration = undefined;
423
+ nextAttemptReason = undefined;
424
+ });
425
+
426
+ pi.on("session_shutdown", async (event, ctx) => {
427
+ if (ownerRegistration !== registration) return;
428
+ const initializations = invalidateAllInitializations(event.reason);
429
+ ownerRegistration = undefined;
430
+ const generation = ++sessionGeneration;
431
+ options.onSessionShutdown?.();
432
+ const active = binding?.registration === registration ? binding : undefined;
433
+ const runtime = active?.runtime ?? runtimeForShutdown;
434
+ lastSnapshot = active ? contextSnapshot(ctx) : lastSnapshot;
435
+ closeBinding(
436
+ active,
437
+ event.reason === "quit"
438
+ ? "Pi shut down before the active trace settled."
439
+ : `Pi session ended before settlement (${event.reason}).`,
440
+ lastSnapshot,
441
+ );
442
+
443
+ let beforeDisposeFailure: { error: unknown } | undefined;
444
+ try {
445
+ await options.beforeSessionDispose?.();
446
+ } catch (error) {
447
+ beforeDisposeFailure = { error };
448
+ }
449
+
450
+ try {
451
+ await joinInitializations(initializations);
452
+ if (disposed || generation !== sessionGeneration) return;
453
+ if (beforeDisposeFailure) throw beforeDisposeFailure.error;
454
+ if (runtime) {
455
+ if (event.reason === "quit" && options.shutdownRuntimeOnQuit) await runtime.shutdown();
456
+ else if (options.flushOnReplacement) await runtime.flush();
457
+ }
458
+ } catch (error) {
459
+ if (!disposed && generation === sessionGeneration) options.onShutdownError?.(error, ctx);
460
+ }
461
+ });
462
+ }
463
+
464
+ function claimPendingProviderGeneration(ctx: ExtensionContext, recorder: TraceRecorder | undefined): void {
465
+ const pending = pendingProviderGeneration;
466
+ if (!pending || !recorder) return;
467
+ pendingProviderGeneration = undefined;
468
+ ensureActiveRun(recorder, ctx, startRootTrace);
469
+ recorder.beginGeneration({
470
+ startedAt: pending.startedAt,
471
+ payload: pending.payload,
472
+ payloadStage: "before_provider_request",
473
+ model: pending.model,
474
+ thinkingLevel: pending.thinkingLevel,
475
+ });
476
+ for (const response of pending.responses) recorder.recordProviderResponse(response.status, response.headers);
477
+ }
478
+
479
+ function startRootTrace(start: (nextRequestId: string | undefined) => void): void {
480
+ const revision = requestIdRevision;
481
+ const nextRequestId = requestId;
482
+ start(nextRequestId);
483
+ if (requestIdRevision === revision) requestId = undefined;
484
+ }
485
+
486
+ function activeRecorderFor(registration: Registration): TraceRecorder | undefined {
487
+ return ownerRegistration === registration && binding?.registration === registration && !binding.runtime.closed
488
+ ? binding.recorder
489
+ : undefined;
490
+ }
491
+
492
+ return controller;
493
+ }
494
+
495
+ function createRecorderOptions(ctx: ExtensionContext, options: PiLangfuseSessionOptions = {}): TraceRecorderOptions {
496
+ const userId = normalizeUserId(options.userId);
497
+ return {
498
+ sessionId: normalizeOptionalString(options.sessionId) ?? ctx.sessionManager.getSessionId(),
499
+ ...(userId ? { userId } : {}),
500
+ cwd: ctx.cwd,
501
+ mode: ctx.mode,
502
+ captureContent: options.captureContent ?? true,
503
+ ...(normalizeOptionalString(options.traceName) ? { traceName: normalizeOptionalString(options.traceName) } : {}),
504
+ ...(options.tags ? { tags: [...options.tags] } : {}),
505
+ ...(options.metadata ? { metadata: { ...options.metadata } } : {}),
506
+ ...(options.onTraceId ? { onTraceId: options.onTraceId } : {}),
507
+ };
508
+ }
509
+
510
+ function snapshotSessionOptions(options: PiLangfuseSessionOptions): PiLangfuseSessionOptions {
511
+ normalizeUserId(options.userId);
512
+ return {
513
+ ...options,
514
+ ...(options.tags ? { tags: [...options.tags] } : {}),
515
+ ...(options.metadata ? { metadata: { ...options.metadata } } : {}),
516
+ };
517
+ }
518
+
519
+ function ensureActiveRun(
520
+ recorder: TraceRecorder,
521
+ ctx: ExtensionContext,
522
+ startRootTrace: (start: (nextRequestId: string | undefined) => void) => void,
523
+ ): void {
524
+ if (!recorder.hasActiveTrace()) {
525
+ startRootTrace((nextRequestId) => {
526
+ recorder.beginAgent({
527
+ prompt: "[automatic continuation]",
528
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, api: ctx.model.api } : undefined,
529
+ snapshot: contextSnapshot(ctx),
530
+ ...(nextRequestId ? { requestId: nextRequestId } : {}),
531
+ });
532
+ });
533
+ }
534
+ if (!recorder.hasActiveAttempt()) recorder.beginAttempt();
535
+ }
536
+
537
+ function contextSnapshot(ctx: ExtensionContext): ContextSnapshot {
538
+ return {
539
+ leafId: typeof ctx.sessionManager.getLeafId === "function" ? ctx.sessionManager.getLeafId() : undefined,
540
+ contextUsage: ctx.getContextUsage(),
541
+ };
542
+ }
543
+
544
+ function findLastAssistant<T extends { role?: string }>(messages: readonly T[]): T | undefined {
545
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
546
+ const message = messages[index];
547
+ if (message?.role === "assistant") return message;
548
+ }
549
+ return undefined;
550
+ }
551
+
552
+ function isRealOutputDelta(event: { type: string; delta?: unknown }): boolean {
553
+ return (
554
+ (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") &&
555
+ typeof event.delta === "string" &&
556
+ event.delta.length > 0
557
+ );
558
+ }
559
+
560
+ function normalizeUserId(value: unknown): string | undefined {
561
+ const userId = normalizeOptionalString(value);
562
+ if (userId && userId.length > 200) throw new Error("Langfuse userId must be at most 200 characters.");
563
+ return userId;
564
+ }
565
+
566
+ function normalizeOptionalString(value: unknown): string | undefined {
567
+ if (typeof value !== "string" || !value.trim()) return undefined;
568
+ const sanitized = sanitizeTraceValue(value.trim());
569
+ return typeof sanitized === "string" && sanitized.trim() ? sanitized.trim() : undefined;
570
+ }
@@ -0,0 +1,17 @@
1
+ import type { CreateLangfuseRuntimeOptions, LangfuseRuntime } from "./runtime-core.js";
2
+
3
+ export {
4
+ createPiLangfuseSession,
5
+ type PiLangfuseSession,
6
+ type PiLangfuseSessionOptions,
7
+ } from "./pi-session.js";
8
+ export type {
9
+ CreateLangfuseRuntimeOptions,
10
+ LangfuseRuntime,
11
+ LangfuseRuntimeConfig,
12
+ } from "./runtime-core.js";
13
+
14
+ export async function createLangfuseRuntime(options: CreateLangfuseRuntimeOptions = {}): Promise<LangfuseRuntime> {
15
+ const runtime = await import("./runtime.js");
16
+ return runtime.createLangfuseRuntime(options);
17
+ }
@@ -0,0 +1,113 @@
1
+ import type { TraceBackend } from "./tracing.js";
2
+
3
+ export interface LangfuseRuntimeConfig {
4
+ publicKey?: string;
5
+ secretKey?: string;
6
+ baseUrl?: string;
7
+ environment?: string;
8
+ release?: string;
9
+ }
10
+
11
+ export interface CreateLangfuseRuntimeOptions {
12
+ config?: LangfuseRuntimeConfig;
13
+ env?: NodeJS.ProcessEnv | false;
14
+ }
15
+
16
+ export interface LangfuseRuntime {
17
+ readonly closed: boolean;
18
+ flush(): Promise<void>;
19
+ shutdown(): Promise<void>;
20
+ }
21
+
22
+ type RuntimeSessionDisposer = (statusMessage: string) => void | Promise<void>;
23
+
24
+ export interface LangfuseRuntimeInternal extends LangfuseRuntime {
25
+ readonly backend: TraceBackend;
26
+ registerSession(disposer: RuntimeSessionDisposer): () => void;
27
+ }
28
+
29
+ const RUNTIME_INTERNAL_KEY = Symbol.for("@narumitw/pi-langfuse/runtime-internal/v2");
30
+
31
+ class ManagedLangfuseRuntime implements LangfuseRuntimeInternal {
32
+ private state: "open" | "closing" | "closed" = "open";
33
+ private readonly sessionDisposers = new Set<RuntimeSessionDisposer>();
34
+ private flushQueue = Promise.resolve();
35
+ private shutdownPromise: Promise<void> | undefined;
36
+
37
+ constructor(readonly backend: TraceBackend) {}
38
+
39
+ get closed(): boolean {
40
+ return this.state !== "open";
41
+ }
42
+
43
+ registerSession(disposer: RuntimeSessionDisposer): () => void {
44
+ if (this.state !== "open") throw new Error("Langfuse runtime is shutting down or already closed.");
45
+ this.sessionDisposers.add(disposer);
46
+ return () => this.sessionDisposers.delete(disposer);
47
+ }
48
+
49
+ flush(): Promise<void> {
50
+ if (this.state !== "open") {
51
+ return Promise.reject(new Error("Langfuse runtime is shutting down or already closed."));
52
+ }
53
+ const operation = this.flushQueue.then(() => this.backend.forceFlush());
54
+ this.flushQueue = operation.then(
55
+ () => undefined,
56
+ () => undefined,
57
+ );
58
+ return operation;
59
+ }
60
+
61
+ shutdown(): Promise<void> {
62
+ if (this.shutdownPromise) return this.shutdownPromise;
63
+ this.state = "closing";
64
+ this.shutdownPromise = this.shutdownNow();
65
+ return this.shutdownPromise;
66
+ }
67
+
68
+ private async shutdownNow(): Promise<void> {
69
+ const errors: unknown[] = [];
70
+ for (const dispose of [...this.sessionDisposers]) {
71
+ try {
72
+ await dispose("Langfuse runtime shut down before the Pi session was disposed.");
73
+ } catch (error) {
74
+ errors.push(error);
75
+ }
76
+ }
77
+ this.sessionDisposers.clear();
78
+
79
+ await this.flushQueue;
80
+ try {
81
+ await this.backend.forceFlush();
82
+ } catch (error) {
83
+ errors.push(error);
84
+ }
85
+ try {
86
+ await this.backend.shutdown();
87
+ } catch (error) {
88
+ errors.push(error);
89
+ } finally {
90
+ this.state = "closed";
91
+ }
92
+
93
+ if (errors.length === 1) throw errors[0];
94
+ if (errors.length > 1) throw new AggregateError(errors, "Langfuse runtime shutdown failed.");
95
+ }
96
+ }
97
+
98
+ export function getLangfuseRuntimeInternal(runtime: LangfuseRuntime): LangfuseRuntimeInternal {
99
+ const internal = (runtime as LangfuseRuntime & { [key: symbol]: LangfuseRuntimeInternal | undefined })[
100
+ RUNTIME_INTERNAL_KEY
101
+ ];
102
+ if (!internal) throw new Error("Langfuse runtime was not created by @narumitw/pi-langfuse.");
103
+ return internal;
104
+ }
105
+
106
+ export function createLangfuseRuntimeFromBackend(backend: TraceBackend): LangfuseRuntime {
107
+ return registerLangfuseRuntime(new ManagedLangfuseRuntime(backend));
108
+ }
109
+
110
+ export function registerLangfuseRuntime(runtime: LangfuseRuntimeInternal): LangfuseRuntimeInternal {
111
+ Object.defineProperty(runtime, RUNTIME_INTERNAL_KEY, { value: runtime });
112
+ return runtime;
113
+ }