@librechat/agents 3.2.44 → 3.2.46

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.
Files changed (77) hide show
  1. package/dist/cjs/graphs/Graph.cjs +17 -8
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +20 -21
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuse.cjs +65 -13
  6. package/dist/cjs/langfuse.cjs.map +1 -1
  7. package/dist/cjs/langfuseConfig.cjs +106 -0
  8. package/dist/cjs/langfuseConfig.cjs.map +1 -0
  9. package/dist/cjs/langfuseRuntimeContext.cjs +48 -0
  10. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -0
  11. package/dist/cjs/langfuseRuntimeScope.cjs +57 -0
  12. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -0
  13. package/dist/cjs/langfuseToolOutputTracing.cjs +14 -119
  14. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +1 -0
  16. package/dist/cjs/messages/format.cjs +1 -1
  17. package/dist/cjs/messages/format.cjs.map +1 -1
  18. package/dist/cjs/run.cjs +25 -12
  19. package/dist/cjs/run.cjs.map +1 -1
  20. package/dist/cjs/tools/ToolNode.cjs +5 -2
  21. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  22. package/dist/cjs/utils/misc.cjs +17 -0
  23. package/dist/cjs/utils/misc.cjs.map +1 -1
  24. package/dist/esm/graphs/Graph.mjs +16 -7
  25. package/dist/esm/graphs/Graph.mjs.map +1 -1
  26. package/dist/esm/instrumentation.mjs +22 -22
  27. package/dist/esm/instrumentation.mjs.map +1 -1
  28. package/dist/esm/langfuse.mjs +65 -14
  29. package/dist/esm/langfuse.mjs.map +1 -1
  30. package/dist/esm/langfuseConfig.mjs +102 -0
  31. package/dist/esm/langfuseConfig.mjs.map +1 -0
  32. package/dist/esm/langfuseRuntimeContext.mjs +44 -0
  33. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -0
  34. package/dist/esm/langfuseRuntimeScope.mjs +53 -0
  35. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -0
  36. package/dist/esm/langfuseToolOutputTracing.mjs +13 -115
  37. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  38. package/dist/esm/main.mjs +2 -2
  39. package/dist/esm/messages/format.mjs +1 -1
  40. package/dist/esm/messages/format.mjs.map +1 -1
  41. package/dist/esm/run.mjs +21 -8
  42. package/dist/esm/run.mjs.map +1 -1
  43. package/dist/esm/tools/ToolNode.mjs +5 -2
  44. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  45. package/dist/esm/utils/misc.mjs +17 -1
  46. package/dist/esm/utils/misc.mjs.map +1 -1
  47. package/dist/types/instrumentation.d.ts +0 -1
  48. package/dist/types/langfuse.d.ts +5 -1
  49. package/dist/types/langfuseConfig.d.ts +7 -0
  50. package/dist/types/langfuseRuntimeContext.d.ts +26 -0
  51. package/dist/types/langfuseRuntimeScope.d.ts +17 -0
  52. package/dist/types/langfuseToolOutputTracing.d.ts +4 -11
  53. package/dist/types/messages/format.d.ts +4 -0
  54. package/dist/types/types/graph.d.ts +6 -0
  55. package/dist/types/utils/misc.d.ts +1 -0
  56. package/package.json +1 -1
  57. package/src/graphs/Graph.ts +20 -13
  58. package/src/instrumentation.ts +33 -29
  59. package/src/langfuse.ts +114 -7
  60. package/src/langfuseConfig.ts +214 -0
  61. package/src/langfuseRuntimeContext.ts +93 -0
  62. package/src/langfuseRuntimeScope.ts +135 -0
  63. package/src/langfuseToolOutputTracing.ts +25 -263
  64. package/src/messages/format.ts +6 -1
  65. package/src/messages/formatAgentMessages.test.ts +76 -0
  66. package/src/run.ts +44 -34
  67. package/src/specs/deterministic-trace-id.test.ts +4 -11
  68. package/src/specs/langfuse-callbacks.test.ts +278 -22
  69. package/src/specs/langfuse-config.test.ts +10 -3
  70. package/src/specs/langfuse-instrumentation.test.ts +32 -12
  71. package/src/specs/langfuse-routing.integration.test.ts +543 -0
  72. package/src/specs/langfuse-runtime-context.test.ts +92 -0
  73. package/src/specs/langfuse-tool-output-tracing.test.ts +353 -5
  74. package/src/tools/ToolNode.ts +10 -5
  75. package/src/tools/__tests__/ToolNode.langfuse.test.ts +21 -11
  76. package/src/types/graph.ts +9 -0
  77. package/src/utils/misc.ts +18 -0
@@ -0,0 +1,543 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { HumanMessage } from '@langchain/core/messages';
4
+ import { FakeListChatModel } from '@langchain/core/utils/testing';
5
+ import { context as otelContext, trace as otelTrace } from '@opentelemetry/api';
6
+ import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
7
+ import type { RunnableConfig } from '@langchain/core/runnables';
8
+ import type { ToolCall } from '@langchain/core/messages/tool';
9
+ import type { Context } from '@opentelemetry/api';
10
+ import type * as t from '@/types';
11
+ import { Constants, ContentTypes, Providers, TitleMethod } from '@/common';
12
+ import { withLangfuseRuntimeScope } from '@/langfuseRuntimeScope';
13
+ import { initializeLangfuseTracing } from '@/instrumentation';
14
+ import { traceIdFromSeed } from '@/langfuseRuntimeContext';
15
+ import * as providers from '@/llm/providers';
16
+ import { Run } from '@/run';
17
+
18
+ type ProcessorParams = {
19
+ publicKey?: string;
20
+ secretKey?: string;
21
+ baseUrl?: string;
22
+ };
23
+
24
+ type SpanStartRecord = {
25
+ name: string;
26
+ params: ProcessorParams;
27
+ traceId: string;
28
+ };
29
+
30
+ const spanStarts: SpanStartRecord[] = [];
31
+ let providerInput:
32
+ | {
33
+ spanProcessors?: SpanProcessor[];
34
+ idGenerator?: {
35
+ generateTraceId: () => string;
36
+ generateSpanId: () => string;
37
+ };
38
+ }
39
+ | undefined;
40
+
41
+ type MockSpan = {
42
+ name: string;
43
+ attributes: Record<string, unknown>;
44
+ addEvent: jest.Mock;
45
+ end: jest.Mock;
46
+ isRecording: jest.Mock;
47
+ recordException: jest.Mock;
48
+ setAttribute: jest.Mock;
49
+ setAttributes: jest.Mock;
50
+ setStatus: jest.Mock;
51
+ spanContext: jest.Mock;
52
+ updateName: jest.Mock;
53
+ };
54
+
55
+ function isOtelContext(value: unknown): value is Context {
56
+ return (
57
+ value != null &&
58
+ typeof value === 'object' &&
59
+ typeof (value as { getValue?: unknown }).getValue === 'function'
60
+ );
61
+ }
62
+
63
+ function createMockSpan(
64
+ name: string,
65
+ parentContext: Context = otelContext.active()
66
+ ): MockSpan {
67
+ const parentTraceId = otelTrace.getSpanContext(parentContext)?.traceId;
68
+ const traceId =
69
+ parentTraceId ??
70
+ providerInput?.idGenerator?.generateTraceId() ??
71
+ 'trace-id';
72
+ const spanId = providerInput?.idGenerator?.generateSpanId() ?? 'span-id';
73
+ const attributes: Record<string, unknown> = {};
74
+ const span = {} as MockSpan;
75
+ Object.assign(span, {
76
+ name,
77
+ attributes,
78
+ addEvent: jest.fn(),
79
+ end: jest.fn(() => {
80
+ for (const processor of providerInput?.spanProcessors ?? []) {
81
+ processor.onEnd(span as never);
82
+ }
83
+ }),
84
+ isRecording: jest.fn(() => true),
85
+ recordException: jest.fn(),
86
+ setAttribute: jest.fn((key: string, value: unknown) => {
87
+ attributes[key] = value;
88
+ return span;
89
+ }),
90
+ setAttributes: jest.fn((next: Record<string, unknown>) => {
91
+ Object.assign(attributes, next);
92
+ return span;
93
+ }),
94
+ setStatus: jest.fn(),
95
+ spanContext: jest.fn(() => ({
96
+ traceId,
97
+ spanId,
98
+ traceFlags: 1,
99
+ })),
100
+ updateName: jest.fn((nextName: string) => {
101
+ span.name = nextName;
102
+ return span;
103
+ }),
104
+ });
105
+
106
+ for (const processor of providerInput?.spanProcessors ?? []) {
107
+ processor.onStart(span as never, parentContext);
108
+ }
109
+ return span;
110
+ }
111
+
112
+ const startSpan = jest.fn((name: string, _options?: unknown, ctx?: unknown) =>
113
+ createMockSpan(name, isOtelContext(ctx) ? ctx : otelContext.active())
114
+ );
115
+
116
+ function getParentContextFromStartActiveSpanArgs(args: unknown[]): Context {
117
+ if (args.length >= 3 && isOtelContext(args[1])) {
118
+ return args[1];
119
+ }
120
+ if (args.length >= 4 && isOtelContext(args[2])) {
121
+ return args[2];
122
+ }
123
+ return otelContext.active();
124
+ }
125
+
126
+ const startActiveSpan = jest.fn((name: string, ...args: unknown[]) => {
127
+ const callback = args[args.length - 1];
128
+ const parentContext = getParentContextFromStartActiveSpanArgs(args);
129
+
130
+ if (typeof callback !== 'function') {
131
+ throw new Error('startActiveSpan mock expected a callback');
132
+ }
133
+
134
+ const span = createMockSpan(name, parentContext);
135
+ const activeContext = otelTrace.setSpan(parentContext, span as never);
136
+ return otelContext.with(activeContext, () => callback(span));
137
+ });
138
+
139
+ jest.mock('@langfuse/otel', () => ({
140
+ LangfuseSpanProcessor: jest.fn().mockImplementation((params) => ({
141
+ forceFlush: jest.fn(),
142
+ onEnd: jest.fn(),
143
+ onStart: jest.fn((span) => {
144
+ spanStarts.push({
145
+ name: span.name,
146
+ params,
147
+ traceId: span.spanContext().traceId,
148
+ });
149
+ }),
150
+ shutdown: jest.fn(),
151
+ })),
152
+ isDefaultExportSpan: jest.fn(() => false),
153
+ }));
154
+
155
+ jest.mock('@opentelemetry/sdk-trace-base', () => ({
156
+ BasicTracerProvider: jest.fn().mockImplementation((input) => {
157
+ providerInput = input;
158
+ return {
159
+ forceFlush: jest.fn(),
160
+ getTracer: jest.fn(() => ({
161
+ startActiveSpan,
162
+ startSpan,
163
+ })),
164
+ shutdown: jest.fn(),
165
+ };
166
+ }),
167
+ }));
168
+
169
+ const echoTool = tool(async ({ text }) => `echo:${text}`, {
170
+ name: 'echo',
171
+ description: 'Echoes text for routing tests.',
172
+ schema: z.object({ text: z.string() }),
173
+ });
174
+
175
+ const callerConfig: Partial<RunnableConfig> & {
176
+ version: 'v1' | 'v2';
177
+ streamMode: string;
178
+ } = {
179
+ configurable: { thread_id: 'routing-thread', user_id: 'routing-user' },
180
+ streamMode: 'values',
181
+ version: 'v2',
182
+ };
183
+
184
+ function tenantLangfuse(tenantId: string): t.LangfuseConfig {
185
+ return {
186
+ enabled: true,
187
+ publicKey: `pk-${tenantId}`,
188
+ secretKey: `sk-${tenantId}`,
189
+ baseUrl: 'https://langfuse.proxy',
190
+ deterministicTraceId: true,
191
+ metadata: { tenantId },
192
+ tags: [`tenant:${tenantId}`],
193
+ toolNodeTracing: { enabled: true },
194
+ toolOutputTracing: { enabled: true },
195
+ };
196
+ }
197
+
198
+ function startsForTenant(tenantId: string): SpanStartRecord[] {
199
+ return spanStarts.filter(
200
+ (record) => record.params.publicKey === `pk-${tenantId}`
201
+ );
202
+ }
203
+
204
+ function expectTenantCredentials(
205
+ starts: SpanStartRecord[],
206
+ tenantId: string
207
+ ): void {
208
+ expect(starts).toEqual(
209
+ expect.arrayContaining([
210
+ expect.objectContaining({
211
+ params: expect.objectContaining({
212
+ publicKey: `pk-${tenantId}`,
213
+ secretKey: `sk-${tenantId}`,
214
+ baseUrl: 'https://langfuse.proxy',
215
+ }),
216
+ }),
217
+ ])
218
+ );
219
+ }
220
+
221
+ function expectNamedSpansUseTraceId({
222
+ starts,
223
+ names,
224
+ traceId,
225
+ }: {
226
+ starts: SpanStartRecord[];
227
+ names: string[];
228
+ traceId: string;
229
+ }): void {
230
+ for (const name of names) {
231
+ const matching = starts.filter((record) => record.name === name);
232
+ expect(matching).not.toHaveLength(0);
233
+ expect(matching.map((record) => record.traceId)).toEqual(
234
+ expect.arrayContaining([traceId])
235
+ );
236
+ expect(
237
+ matching.filter((record) => record.traceId !== traceId)
238
+ ).toHaveLength(0);
239
+ }
240
+ }
241
+
242
+ function expectOnlyTraceIds(
243
+ starts: SpanStartRecord[],
244
+ allowedTraceIds: string[]
245
+ ): void {
246
+ const allowed = new Set(allowedTraceIds);
247
+ expect(starts.filter((record) => !allowed.has(record.traceId))).toHaveLength(
248
+ 0
249
+ );
250
+ }
251
+
252
+ function expectNoCrossTenantTrace({
253
+ tenantId,
254
+ otherTenantId,
255
+ traceId,
256
+ }: {
257
+ tenantId: string;
258
+ otherTenantId: string;
259
+ traceId: string;
260
+ }): void {
261
+ expect(
262
+ startsForTenant(otherTenantId).filter(
263
+ (record) => record.traceId === traceId
264
+ )
265
+ ).toHaveLength(0);
266
+ expect(startsForTenant(tenantId)).toEqual(
267
+ expect.arrayContaining([expect.objectContaining({ traceId })])
268
+ );
269
+ }
270
+
271
+ function createAgent(tenantId: string): t.AgentInputs {
272
+ return {
273
+ agentId: 'parent',
274
+ name: `Parent ${tenantId}`,
275
+ provider: Providers.OPENAI,
276
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
277
+ instructions: 'Use tools when asked.',
278
+ maxContextTokens: 8000,
279
+ tools: [echoTool],
280
+ subagentConfigs: [
281
+ {
282
+ type: 'researcher',
283
+ name: 'Researcher',
284
+ description: 'Answers delegated research tasks.',
285
+ agentInputs: {
286
+ agentId: 'researcher',
287
+ name: `Researcher ${tenantId}`,
288
+ provider: Providers.OPENAI,
289
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
290
+ instructions: 'Answer delegated tasks briefly.',
291
+ maxContextTokens: 8000,
292
+ },
293
+ },
294
+ ],
295
+ };
296
+ }
297
+
298
+ async function runTenantFlow(tenantId: string): Promise<void> {
299
+ const runId = `routing-${tenantId}`;
300
+ const run = await Run.create<t.IState>({
301
+ runId,
302
+ graphConfig: {
303
+ type: 'standard',
304
+ agents: [createAgent(tenantId)],
305
+ },
306
+ langfuse: tenantLangfuse(tenantId),
307
+ returnContent: true,
308
+ skipCleanup: true,
309
+ });
310
+
311
+ const toolCalls: ToolCall[] = [
312
+ {
313
+ id: `call_echo_${tenantId}`,
314
+ name: 'echo',
315
+ args: { text: `hello ${tenantId}` },
316
+ type: 'tool_call',
317
+ },
318
+ {
319
+ id: `call_subagent_${tenantId}`,
320
+ name: Constants.SUBAGENT,
321
+ args: {
322
+ description: `Research ${tenantId}`,
323
+ subagent_type: 'researcher',
324
+ },
325
+ type: 'tool_call',
326
+ },
327
+ ];
328
+ run.Graph?.overrideTestModel(
329
+ [`Using tools for ${tenantId}.`, `Final answer for ${tenantId}.`],
330
+ 1,
331
+ toolCalls
332
+ );
333
+
334
+ await run.processStream(
335
+ { messages: [new HumanMessage(`Use tools for ${tenantId}`)] },
336
+ {
337
+ ...callerConfig,
338
+ configurable: {
339
+ thread_id: `thread-${tenantId}`,
340
+ user_id: `user-${tenantId}`,
341
+ },
342
+ }
343
+ );
344
+
345
+ await run.generateTitle({
346
+ provider: Providers.OPENAI,
347
+ inputText: `Use tools for ${tenantId}`,
348
+ titleMethod: TitleMethod.COMPLETION,
349
+ contentParts: [
350
+ { type: ContentTypes.TEXT, text: `Final answer for ${tenantId}.` },
351
+ ],
352
+ chainOptions: {
353
+ configurable: {
354
+ thread_id: `thread-${tenantId}`,
355
+ user_id: `user-${tenantId}`,
356
+ },
357
+ },
358
+ });
359
+ }
360
+
361
+ const compactingTokenCounter: t.TokenCounter = (message) => {
362
+ if (message._getType() === 'system') {
363
+ return 1;
364
+ }
365
+ const content = message.content;
366
+ return typeof content === 'string'
367
+ ? content.length
368
+ : JSON.stringify(content).length;
369
+ };
370
+
371
+ async function runTenantSummarizationFlow(tenantId: string): Promise<void> {
372
+ const runId = `routing-summary-${tenantId}`;
373
+ const run = await Run.create<t.IState>({
374
+ runId,
375
+ graphConfig: {
376
+ type: 'standard',
377
+ agents: [
378
+ {
379
+ agentId: 'parent',
380
+ name: `Parent ${tenantId}`,
381
+ provider: Providers.OPENAI,
382
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
383
+ instructions: 'Summarize when context is full.',
384
+ maxContextTokens: 120,
385
+ summarizationEnabled: true,
386
+ summarizationConfig: {
387
+ retainRecent: { turns: 0 },
388
+ },
389
+ },
390
+ ],
391
+ },
392
+ langfuse: tenantLangfuse(tenantId),
393
+ tokenCounter: compactingTokenCounter,
394
+ returnContent: true,
395
+ skipCleanup: true,
396
+ });
397
+
398
+ run.Graph?.overrideTestModel([`After summary for ${tenantId}.`], 1);
399
+
400
+ await run.processStream(
401
+ {
402
+ messages: [
403
+ new HumanMessage(`${tenantId} old context `.repeat(8)),
404
+ new HumanMessage(`${tenantId} more old context `.repeat(8)),
405
+ new HumanMessage(`Continue for ${tenantId}`),
406
+ ],
407
+ },
408
+ {
409
+ ...callerConfig,
410
+ configurable: {
411
+ thread_id: `summary-thread-${tenantId}`,
412
+ user_id: `summary-user-${tenantId}`,
413
+ },
414
+ }
415
+ );
416
+ }
417
+
418
+ describe('Langfuse per-run routing integration', () => {
419
+ let getChatModelClassSpy: jest.SpyInstance;
420
+ const originalGetChatModelClass = providers.getChatModelClass;
421
+
422
+ beforeEach(() => {
423
+ jest.clearAllMocks();
424
+ spanStarts.length = 0;
425
+ delete process.env.LANGFUSE_PUBLIC_KEY;
426
+ delete process.env.LANGFUSE_SECRET_KEY;
427
+ delete process.env.LANGFUSE_BASE_URL;
428
+ delete process.env.LANGFUSE_BASEURL;
429
+ getChatModelClassSpy = jest
430
+ .spyOn(providers, 'getChatModelClass')
431
+ .mockImplementation(((provider: Providers) => {
432
+ if (provider === Providers.OPENAI) {
433
+ return class extends FakeListChatModel {
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
+ constructor(_options: any) {
436
+ super({ responses: ['provider response'] });
437
+ }
438
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
439
+ } as any;
440
+ }
441
+ return originalGetChatModelClass(provider);
442
+ }) as typeof providers.getChatModelClass);
443
+ });
444
+
445
+ afterEach(() => {
446
+ getChatModelClassSpy.mockRestore();
447
+ });
448
+
449
+ it('routes parallel root, model, tool, subagent, and title spans to each run config', async () => {
450
+ await Promise.all([runTenantFlow('tenant-a'), runTenantFlow('tenant-b')]);
451
+
452
+ for (const tenantId of ['tenant-a', 'tenant-b']) {
453
+ const starts = startsForTenant(tenantId);
454
+ const otherTenantId = tenantId === 'tenant-a' ? 'tenant-b' : 'tenant-a';
455
+ const runTraceId = traceIdFromSeed(`routing-${tenantId}`);
456
+ const titleTraceId = traceIdFromSeed(`title-routing-${tenantId}`);
457
+
458
+ expectTenantCredentials(starts, tenantId);
459
+ expectNamedSpansUseTraceId({
460
+ starts,
461
+ traceId: runTraceId,
462
+ names: [
463
+ `LibreChat Agent: Parent ${tenantId}`,
464
+ 'FakeChatModel',
465
+ 'tool_batch',
466
+ 'subagent',
467
+ ],
468
+ });
469
+ expectNamedSpansUseTraceId({
470
+ starts,
471
+ traceId: titleTraceId,
472
+ names: [`LibreChat Title: Parent ${tenantId}`, 'CompletionTitleChain'],
473
+ });
474
+ expectNoCrossTenantTrace({
475
+ tenantId,
476
+ otherTenantId,
477
+ traceId: runTraceId,
478
+ });
479
+ expectNoCrossTenantTrace({
480
+ tenantId,
481
+ otherTenantId,
482
+ traceId: titleTraceId,
483
+ });
484
+ }
485
+ });
486
+
487
+ it('routes parallel summarization spans to each run config', async () => {
488
+ await Promise.all([
489
+ runTenantSummarizationFlow('tenant-a'),
490
+ runTenantSummarizationFlow('tenant-b'),
491
+ ]);
492
+
493
+ for (const tenantId of ['tenant-a', 'tenant-b']) {
494
+ const otherTenantId = tenantId === 'tenant-a' ? 'tenant-b' : 'tenant-a';
495
+ const starts = startsForTenant(tenantId);
496
+ const summaryTraceId = traceIdFromSeed(`routing-summary-${tenantId}`);
497
+
498
+ expectTenantCredentials(starts, tenantId);
499
+ expectOnlyTraceIds(starts, [summaryTraceId]);
500
+ expectNamedSpansUseTraceId({
501
+ starts,
502
+ traceId: summaryTraceId,
503
+ names: [
504
+ `LibreChat Agent: Parent ${tenantId}`,
505
+ 'summarize=parent',
506
+ 'summarization:cache_hit_compaction',
507
+ 'FakeChatModel',
508
+ ],
509
+ });
510
+ expectNoCrossTenantTrace({
511
+ tenantId,
512
+ otherTenantId,
513
+ traceId: summaryTraceId,
514
+ });
515
+ }
516
+ });
517
+
518
+ it('routes spans from captured OTel context after ALS scope exits', () => {
519
+ const langfuse = tenantLangfuse('tenant-otel');
520
+ initializeLangfuseTracing(langfuse);
521
+
522
+ let capturedContext: Context | undefined;
523
+ withLangfuseRuntimeScope({ langfuse }, () => {
524
+ capturedContext = otelContext.active();
525
+ });
526
+
527
+ expect(capturedContext).toBeDefined();
528
+ createMockSpan('otel-context-only', capturedContext);
529
+
530
+ expect(startsForTenant('tenant-otel')).toEqual(
531
+ expect.arrayContaining([
532
+ expect.objectContaining({
533
+ name: 'otel-context-only',
534
+ params: expect.objectContaining({
535
+ publicKey: 'pk-tenant-otel',
536
+ secretKey: 'sk-tenant-otel',
537
+ baseUrl: 'https://langfuse.proxy',
538
+ }),
539
+ }),
540
+ ])
541
+ );
542
+ });
543
+ });
@@ -0,0 +1,92 @@
1
+ import { context } from '@opentelemetry/api';
2
+ import {
3
+ getLangfuseRuntimeConfig,
4
+ getLangfuseRuntimeToolOutputTracingConfig,
5
+ getTraceIdSeed,
6
+ runWithLangfuseRuntimeContext,
7
+ runWithTraceIdSeed,
8
+ traceIdFromSeed,
9
+ } from '@/langfuseRuntimeContext';
10
+ import {
11
+ resolveTraceIdSeedForSpan,
12
+ withLangfuseRuntimeScope,
13
+ } from '@/langfuseRuntimeScope';
14
+
15
+ describe('Langfuse runtime context', () => {
16
+ it('exposes tenant config and deterministic trace seed inside the scope', () => {
17
+ const langfuse = {
18
+ publicKey: 'pk-runtime',
19
+ secretKey: 'sk-runtime',
20
+ baseUrl: 'https://langfuse.runtime',
21
+ };
22
+
23
+ runWithLangfuseRuntimeContext(
24
+ { langfuse, traceIdSeed: 'run-runtime' },
25
+ () => {
26
+ expect(getLangfuseRuntimeConfig()).toBe(langfuse);
27
+ expect(getTraceIdSeed()).toBe('run-runtime');
28
+ }
29
+ );
30
+
31
+ expect(getLangfuseRuntimeConfig()).toBeUndefined();
32
+ expect(getTraceIdSeed()).toBeUndefined();
33
+ });
34
+
35
+ it('merges nested scopes without dropping inherited tenant config', () => {
36
+ const langfuse = {
37
+ publicKey: 'pk-parent',
38
+ secretKey: 'sk-parent',
39
+ baseUrl: 'https://langfuse.parent',
40
+ };
41
+
42
+ runWithLangfuseRuntimeContext(
43
+ { langfuse, traceIdSeed: 'parent-run' },
44
+ () => {
45
+ runWithTraceIdSeed('child-run', () => {
46
+ expect(getLangfuseRuntimeConfig()).toBe(langfuse);
47
+ expect(getTraceIdSeed()).toBe('child-run');
48
+ });
49
+
50
+ expect(getLangfuseRuntimeConfig()).toBe(langfuse);
51
+ expect(getTraceIdSeed()).toBe('parent-run');
52
+ }
53
+ );
54
+ });
55
+
56
+ it('carries resolved tool-output tracing config in the same runtime scope', () => {
57
+ const toolOutputTracing = {
58
+ enabled: false,
59
+ redactedToolNames: new Set(['search']),
60
+ redactedToolNameMatchMode: 'exact' as const,
61
+ redactionText: '[redacted]',
62
+ };
63
+
64
+ runWithLangfuseRuntimeContext({ toolOutputTracing }, () => {
65
+ expect(getLangfuseRuntimeToolOutputTracingConfig()).toBe(
66
+ toolOutputTracing
67
+ );
68
+ });
69
+
70
+ expect(getLangfuseRuntimeToolOutputTracingConfig()).toBeUndefined();
71
+ });
72
+
73
+ it('also exposes deterministic trace seeds through OTel context', () => {
74
+ withLangfuseRuntimeScope({ traceIdSeed: 'run-otel' }, () => {
75
+ expect(resolveTraceIdSeedForSpan(context.active())).toBe('run-otel');
76
+ });
77
+
78
+ expect(resolveTraceIdSeedForSpan(context.active())).toBeUndefined();
79
+ });
80
+
81
+ it('ignores empty trace seeds', () => {
82
+ const result = runWithTraceIdSeed(' ', () => getTraceIdSeed());
83
+
84
+ expect(result).toBeUndefined();
85
+ });
86
+
87
+ it('derives Langfuse-compatible deterministic trace ids', () => {
88
+ expect(traceIdFromSeed('run-runtime')).toBe(
89
+ '5b8a8af5718b2eba96b83a2b8fbfa7f4'
90
+ );
91
+ });
92
+ });