@librechat/agents 3.2.44 → 3.2.45

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 (70) 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/run.cjs +25 -12
  17. package/dist/cjs/run.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +5 -2
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/utils/misc.cjs +17 -0
  21. package/dist/cjs/utils/misc.cjs.map +1 -1
  22. package/dist/esm/graphs/Graph.mjs +16 -7
  23. package/dist/esm/graphs/Graph.mjs.map +1 -1
  24. package/dist/esm/instrumentation.mjs +22 -22
  25. package/dist/esm/instrumentation.mjs.map +1 -1
  26. package/dist/esm/langfuse.mjs +65 -14
  27. package/dist/esm/langfuse.mjs.map +1 -1
  28. package/dist/esm/langfuseConfig.mjs +102 -0
  29. package/dist/esm/langfuseConfig.mjs.map +1 -0
  30. package/dist/esm/langfuseRuntimeContext.mjs +44 -0
  31. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -0
  32. package/dist/esm/langfuseRuntimeScope.mjs +53 -0
  33. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -0
  34. package/dist/esm/langfuseToolOutputTracing.mjs +13 -115
  35. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  36. package/dist/esm/main.mjs +2 -2
  37. package/dist/esm/run.mjs +21 -8
  38. package/dist/esm/run.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +5 -2
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/esm/utils/misc.mjs +17 -1
  42. package/dist/esm/utils/misc.mjs.map +1 -1
  43. package/dist/types/instrumentation.d.ts +0 -1
  44. package/dist/types/langfuse.d.ts +5 -1
  45. package/dist/types/langfuseConfig.d.ts +7 -0
  46. package/dist/types/langfuseRuntimeContext.d.ts +26 -0
  47. package/dist/types/langfuseRuntimeScope.d.ts +17 -0
  48. package/dist/types/langfuseToolOutputTracing.d.ts +4 -11
  49. package/dist/types/types/graph.d.ts +6 -0
  50. package/dist/types/utils/misc.d.ts +1 -0
  51. package/package.json +1 -1
  52. package/src/graphs/Graph.ts +20 -13
  53. package/src/instrumentation.ts +33 -29
  54. package/src/langfuse.ts +114 -7
  55. package/src/langfuseConfig.ts +214 -0
  56. package/src/langfuseRuntimeContext.ts +93 -0
  57. package/src/langfuseRuntimeScope.ts +135 -0
  58. package/src/langfuseToolOutputTracing.ts +25 -263
  59. package/src/run.ts +44 -34
  60. package/src/specs/deterministic-trace-id.test.ts +4 -11
  61. package/src/specs/langfuse-callbacks.test.ts +278 -22
  62. package/src/specs/langfuse-config.test.ts +10 -3
  63. package/src/specs/langfuse-instrumentation.test.ts +32 -12
  64. package/src/specs/langfuse-routing.integration.test.ts +543 -0
  65. package/src/specs/langfuse-runtime-context.test.ts +92 -0
  66. package/src/specs/langfuse-tool-output-tracing.test.ts +353 -5
  67. package/src/tools/ToolNode.ts +10 -5
  68. package/src/tools/__tests__/ToolNode.langfuse.test.ts +21 -11
  69. package/src/types/graph.ts +9 -0
  70. package/src/utils/misc.ts +18 -0
@@ -1,50 +1,109 @@
1
1
  import { HumanMessage } from '@langchain/core/messages';
2
2
  import { CallbackManager } from '@langchain/core/callbacks/manager';
3
+ import { context as otelContext, trace as otelTrace } from '@opentelemetry/api';
3
4
  import type * as t from '@/types';
5
+ import { traceIdFromSeed } from '@/langfuseRuntimeContext';
4
6
  import { Providers } from '@/common';
5
7
  import { Run } from '@/run';
6
8
 
7
- const mockSpan = {
8
- end: jest.fn(),
9
- spanContext: jest.fn(() => ({
10
- traceId: 'trace-id',
11
- spanId: 'span-id',
12
- traceFlags: 1,
13
- })),
14
- setAttributes: jest.fn(),
15
- setStatus: jest.fn(),
9
+ const mockProcessorStarts: Array<{
10
+ params: unknown;
11
+ traceId: string;
12
+ }> = [];
13
+ const mockSpanAttributeSets: Array<Record<string, unknown>> = [];
14
+ let mockProviderInput:
15
+ | {
16
+ spanProcessors?: Array<{
17
+ onStart?: (span: unknown, parentContext: unknown) => void;
18
+ onEnd?: (span: unknown) => void;
19
+ }>;
20
+ idGenerator?: {
21
+ generateTraceId: () => string;
22
+ generateSpanId: () => string;
23
+ };
24
+ }
25
+ | undefined;
26
+
27
+ const createMockSpan = (traceIdOverride?: string) => {
28
+ const traceId =
29
+ traceIdOverride ??
30
+ mockProviderInput?.idGenerator?.generateTraceId() ??
31
+ 'trace-id';
32
+ const spanId = mockProviderInput?.idGenerator?.generateSpanId() ?? 'span-id';
33
+ const span = {
34
+ end: jest.fn(() => {
35
+ for (const processor of mockProviderInput?.spanProcessors ?? []) {
36
+ processor.onEnd?.(span);
37
+ }
38
+ }),
39
+ spanContext: jest.fn(() => ({
40
+ traceId,
41
+ spanId,
42
+ traceFlags: 1,
43
+ })),
44
+ setAttributes: jest.fn((attributes: Record<string, unknown>) => {
45
+ mockSpanAttributeSets.push(attributes);
46
+ }),
47
+ setStatus: jest.fn(),
48
+ attributes: {},
49
+ };
50
+ for (const processor of mockProviderInput?.spanProcessors ?? []) {
51
+ processor.onStart?.(span, otelContext.active());
52
+ }
53
+ return span;
16
54
  };
17
- const mockStartSpan = jest.fn(() => mockSpan);
55
+
56
+ const mockStartSpan = jest.fn(() => createMockSpan());
18
57
  const mockStartActiveSpan = jest.fn(
19
58
  (
20
59
  _name: string,
21
60
  _options: unknown,
22
- _context: unknown,
23
- callback: (span: typeof mockSpan) => unknown
24
- ) => callback(mockSpan)
61
+ activeContext: Parameters<typeof otelTrace.getSpanContext>[0],
62
+ callback: (span: ReturnType<typeof createMockSpan>) => unknown
63
+ ) =>
64
+ callback(createMockSpan(otelTrace.getSpanContext(activeContext)?.traceId))
25
65
  );
26
66
  const mockForceFlush = jest.fn();
27
67
  const mockShutdown = jest.fn();
28
68
 
29
69
  jest.mock('@langfuse/otel', () => ({
30
- LangfuseSpanProcessor: jest.fn().mockImplementation(() => ({})),
70
+ LangfuseSpanProcessor: jest.fn().mockImplementation((params) => ({
71
+ forceFlush: jest.fn(),
72
+ onEnd: jest.fn(),
73
+ onStart: jest.fn((span) => {
74
+ mockProcessorStarts.push({
75
+ params,
76
+ traceId: span.spanContext().traceId,
77
+ });
78
+ }),
79
+ shutdown: jest.fn(),
80
+ })),
31
81
  isDefaultExportSpan: jest.fn(() => false),
32
82
  }));
33
83
 
34
84
  jest.mock('@opentelemetry/sdk-trace-base', () => ({
35
- BasicTracerProvider: jest.fn().mockImplementation(() => ({
36
- forceFlush: mockForceFlush,
37
- getTracer: jest.fn(() => ({
38
- startActiveSpan: mockStartActiveSpan,
39
- startSpan: mockStartSpan,
40
- })),
41
- shutdown: mockShutdown,
42
- })),
85
+ BasicTracerProvider: jest.fn().mockImplementation((input) => {
86
+ mockProviderInput = input;
87
+ return {
88
+ forceFlush: mockForceFlush,
89
+ getTracer: jest.fn(() => ({
90
+ startActiveSpan: mockStartActiveSpan,
91
+ startSpan: mockStartSpan,
92
+ })),
93
+ shutdown: mockShutdown,
94
+ };
95
+ }),
43
96
  }));
44
97
 
45
98
  describe('Langfuse callback composition', () => {
46
99
  beforeEach(() => {
47
100
  jest.clearAllMocks();
101
+ mockProcessorStarts.length = 0;
102
+ mockSpanAttributeSets.length = 0;
103
+ delete process.env.LANGFUSE_PUBLIC_KEY;
104
+ delete process.env.LANGFUSE_SECRET_KEY;
105
+ delete process.env.LANGFUSE_BASE_URL;
106
+ delete process.env.LANGFUSE_BASEURL;
48
107
  delete process.env.LANGFUSE_FORCE_FLUSH_ON_DISPOSE;
49
108
  });
50
109
 
@@ -183,6 +242,203 @@ describe('Langfuse callback composition', () => {
183
242
  );
184
243
  });
185
244
 
245
+ it('binds handler callback spans to their own Langfuse config and trace seed', async () => {
246
+ const { createLangfuseHandler } = await import('@/langfuse');
247
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
248
+ const tenantA = {
249
+ publicKey: 'pk-tenant-a',
250
+ secretKey: 'sk-tenant-a',
251
+ baseUrl: 'https://langfuse.proxy',
252
+ deterministicTraceId: true,
253
+ };
254
+ const tenantB = {
255
+ publicKey: 'pk-tenant-b',
256
+ secretKey: 'sk-tenant-b',
257
+ baseUrl: 'https://langfuse.proxy',
258
+ deterministicTraceId: true,
259
+ };
260
+ initializeLangfuseTracing(tenantA);
261
+ initializeLangfuseTracing(tenantB);
262
+
263
+ const handlerA = createLangfuseHandler({
264
+ langfuse: tenantA,
265
+ traceIdSeed: 'run-tenant-a',
266
+ });
267
+ const handlerB = createLangfuseHandler({
268
+ langfuse: tenantB,
269
+ traceIdSeed: 'run-tenant-b',
270
+ });
271
+
272
+ await Promise.all([
273
+ handlerA?.handleChainStart(
274
+ { lc: 1, type: 'not_implemented', id: ['TenantAChain'] },
275
+ { input: 'tenant a' },
276
+ 'lc-run-a'
277
+ ),
278
+ handlerB?.handleChainStart(
279
+ { lc: 1, type: 'not_implemented', id: ['TenantBChain'] },
280
+ { input: 'tenant b' },
281
+ 'lc-run-b'
282
+ ),
283
+ ]);
284
+
285
+ expect(mockProcessorStarts).toEqual(
286
+ expect.arrayContaining([
287
+ expect.objectContaining({
288
+ params: expect.objectContaining({
289
+ publicKey: 'pk-tenant-a',
290
+ secretKey: 'sk-tenant-a',
291
+ baseUrl: 'https://langfuse.proxy',
292
+ }),
293
+ traceId: traceIdFromSeed('run-tenant-a'),
294
+ }),
295
+ expect.objectContaining({
296
+ params: expect.objectContaining({
297
+ publicKey: 'pk-tenant-b',
298
+ secretKey: 'sk-tenant-b',
299
+ baseUrl: 'https://langfuse.proxy',
300
+ }),
301
+ traceId: traceIdFromSeed('run-tenant-b'),
302
+ }),
303
+ ])
304
+ );
305
+ });
306
+
307
+ it('preserves an active agent Langfuse runtime scope for callback-created spans', async () => {
308
+ const { createLangfuseHandler } = await import('@/langfuse');
309
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
310
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
311
+ const runLangfuse = {
312
+ publicKey: 'pk-run',
313
+ secretKey: 'sk-run',
314
+ baseUrl: 'https://langfuse.run',
315
+ deterministicTraceId: true,
316
+ };
317
+ const agentLangfuse = {
318
+ publicKey: 'pk-agent',
319
+ secretKey: 'sk-agent',
320
+ baseUrl: 'https://langfuse.agent',
321
+ deterministicTraceId: true,
322
+ };
323
+ initializeLangfuseTracing(runLangfuse);
324
+ initializeLangfuseTracing(agentLangfuse);
325
+ const streamHandler = createLangfuseHandler({
326
+ langfuse: runLangfuse,
327
+ traceIdSeed: 'run-seed',
328
+ });
329
+
330
+ await withLangfuseRuntimeScope(
331
+ { langfuse: agentLangfuse, traceIdSeed: 'agent-seed' },
332
+ () =>
333
+ streamHandler?.handleChainStart(
334
+ { lc: 1, type: 'not_implemented', id: ['AgentScopedChain'] },
335
+ { input: 'agent scoped' },
336
+ 'lc-agent-run'
337
+ )
338
+ );
339
+
340
+ expect(mockProcessorStarts).toContainEqual(
341
+ expect.objectContaining({
342
+ params: expect.objectContaining({
343
+ publicKey: 'pk-agent',
344
+ secretKey: 'sk-agent',
345
+ baseUrl: 'https://langfuse.agent',
346
+ }),
347
+ traceId: traceIdFromSeed('agent-seed'),
348
+ })
349
+ );
350
+ expect(mockProcessorStarts).not.toContainEqual(
351
+ expect.objectContaining({
352
+ params: expect.objectContaining({
353
+ publicKey: 'pk-run',
354
+ secretKey: 'sk-run',
355
+ baseUrl: 'https://langfuse.run',
356
+ }),
357
+ traceId: traceIdFromSeed('run-seed'),
358
+ })
359
+ );
360
+ });
361
+
362
+ it('attaches configured trace attributes to Langfuse callback spans', async () => {
363
+ const { createLangfuseHandler } = await import('@/langfuse');
364
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
365
+ const langfuse = {
366
+ publicKey: 'pk-tenant-a',
367
+ secretKey: 'sk-tenant-a',
368
+ baseUrl: 'https://langfuse.proxy',
369
+ librechatTraceAttributes: {
370
+ 'librechat.langfuse.tenant_export.enabled': 'true',
371
+ 'librechat.langfuse.destination': 'eu',
372
+ ignored: '',
373
+ },
374
+ };
375
+ initializeLangfuseTracing(langfuse);
376
+ const handler = createLangfuseHandler({ langfuse });
377
+
378
+ await handler?.handleChainStart(
379
+ { lc: 1, type: 'not_implemented', id: ['TenantAChain'] },
380
+ { input: 'tenant a' },
381
+ 'lc-run-a'
382
+ );
383
+
384
+ expect(mockSpanAttributeSets).toContainEqual({
385
+ 'librechat.langfuse.tenant_export.enabled': 'true',
386
+ 'librechat.langfuse.destination': 'eu',
387
+ });
388
+ });
389
+
390
+ it('uses deterministic trace ids when tracing is configured from env only', async () => {
391
+ process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
392
+ process.env.LANGFUSE_SECRET_KEY = 'sk-env';
393
+ process.env.LANGFUSE_BASE_URL = 'https://langfuse.env';
394
+
395
+ const runId = 'test-langfuse-env-deterministic-run';
396
+ const run = await Run.create<t.IState>({
397
+ runId,
398
+ graphConfig: {
399
+ type: 'standard',
400
+ agents: [
401
+ {
402
+ agentId: 'agent_abc123',
403
+ name: 'DWAINE',
404
+ provider: Providers.OPENAI,
405
+ clientOptions: { model: 'gpt-4' },
406
+ tools: [],
407
+ },
408
+ ],
409
+ },
410
+ langfuse: {
411
+ deterministicTraceId: true,
412
+ metadata: { 'librechat.tenant.id': 'tenant-env' },
413
+ tags: ['tenant:tenant-env'],
414
+ },
415
+ skipCleanup: true,
416
+ });
417
+
418
+ run.Graph?.overrideTestModel(['hello']);
419
+
420
+ await run.processStream(
421
+ { messages: [new HumanMessage('hello')] },
422
+ {
423
+ configurable: { thread_id: 'thread-1', user_id: 'user-1' },
424
+ version: 'v2' as const,
425
+ }
426
+ );
427
+
428
+ expect(mockProcessorStarts).toEqual(
429
+ expect.arrayContaining([
430
+ expect.objectContaining({
431
+ params: expect.objectContaining({
432
+ publicKey: 'pk-env',
433
+ secretKey: 'sk-env',
434
+ baseUrl: 'https://langfuse.env',
435
+ }),
436
+ traceId: traceIdFromSeed(runId),
437
+ }),
438
+ ])
439
+ );
440
+ });
441
+
186
442
  it('adds current agent metadata when a stream Langfuse callback already exists', async () => {
187
443
  const metadataSpy = jest.fn();
188
444
  const { createLangfuseHandler } = await import('@/langfuse');
@@ -9,9 +9,16 @@ import {
9
9
 
10
10
  const mockForceFlush = jest.fn();
11
11
 
12
- jest.mock('@langfuse/langchain', () => ({
13
- CallbackHandler: jest.fn().mockImplementation((params) => ({ params })),
14
- }));
12
+ jest.mock('@langfuse/langchain', () => {
13
+ const CallbackHandler = jest.fn(function (
14
+ this: { params?: unknown; name?: string },
15
+ params: unknown
16
+ ) {
17
+ this.params = params;
18
+ this.name = 'LangfuseCallbackHandler';
19
+ });
20
+ return { CallbackHandler };
21
+ });
15
22
 
16
23
  jest.mock('@langfuse/tracing', () => ({
17
24
  getLangfuseTracerProvider: jest.fn(() => ({
@@ -33,16 +33,17 @@ type BasicTracerProviderInput = {
33
33
  shutdown?: unknown;
34
34
  }>;
35
35
  };
36
- type RoutingSpanProcessorForTest = BasicTracerProviderInput['spanProcessors'][0] & {
37
- processors: Map<
38
- string,
39
- {
40
- fallbackConfig?: {
41
- enabled?: boolean;
42
- };
43
- }
44
- >;
45
- };
36
+ type RoutingSpanProcessorForTest =
37
+ BasicTracerProviderInput['spanProcessors'][0] & {
38
+ processors: Map<
39
+ string,
40
+ {
41
+ fallbackConfig?: {
42
+ enabled?: boolean;
43
+ };
44
+ }
45
+ >;
46
+ };
46
47
  const mockBasicTracerProvider = jest.fn(
47
48
  (_input?: BasicTracerProviderInput) => mockTracerProvider
48
49
  );
@@ -217,6 +218,25 @@ describe('Langfuse instrumentation', () => {
217
218
  expect(mockSetLangfuseTracerProvider).toHaveBeenCalledTimes(1);
218
219
  });
219
220
 
221
+ it('does not store raw secret keys in processor cache keys', async () => {
222
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
223
+ initializeLangfuseTracing({
224
+ publicKey: 'pk-cache',
225
+ secretKey: 'sk-cache-secret',
226
+ baseUrl: 'https://langfuse.cache',
227
+ });
228
+
229
+ const providerInput = mockBasicTracerProvider.mock
230
+ .calls[0][0] as BasicTracerProviderInput;
231
+ const routingProcessor = providerInput
232
+ .spanProcessors[0] as RoutingSpanProcessorForTest;
233
+ const processorKeys = Array.from(routingProcessor.processors.keys());
234
+
235
+ expect(processorKeys).toHaveLength(1);
236
+ expect(processorKeys[0]).toContain('secretKeyHash');
237
+ expect(processorKeys[0]).not.toContain('sk-cache-secret');
238
+ });
239
+
220
240
  it('passes explicit redaction config into the redacting processor fallback', async () => {
221
241
  const { initializeLangfuseTracing } = await import('@/instrumentation');
222
242
  initializeLangfuseTracing({
@@ -228,8 +248,8 @@ describe('Langfuse instrumentation', () => {
228
248
 
229
249
  const providerInput = mockBasicTracerProvider.mock
230
250
  .calls[0][0] as BasicTracerProviderInput;
231
- const routingProcessor =
232
- providerInput.spanProcessors[0] as RoutingSpanProcessorForTest;
251
+ const routingProcessor = providerInput
252
+ .spanProcessors[0] as RoutingSpanProcessorForTest;
233
253
  const childProcessors = Array.from(routingProcessor.processors.values());
234
254
  expect(childProcessors[0]?.fallbackConfig).toMatchObject({
235
255
  enabled: false,