|
1 | | -import { describe, expect, it } from 'vitest'; |
| 1 | +import { describe, expect, it, vi } from 'vitest'; |
2 | 2 | import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE } from '../../../src/tracing/ai/gen-ai-attributes'; |
3 | 3 | import type { LangChainMessage } from '../../../src/tracing/langchain/types'; |
4 | | -import { extractChatModelRequestAttributes, normalizeLangChainMessages } from '../../../src/tracing/langchain/utils'; |
| 4 | +import { |
| 5 | + _INTERNAL_mergeLangChainCallbackHandler, |
| 6 | + extractChatModelRequestAttributes, |
| 7 | + normalizeLangChainMessages, |
| 8 | +} from '../../../src/tracing/langchain/utils'; |
5 | 9 |
|
6 | 10 | describe('normalizeLangChainMessages', () => { |
7 | 11 | it('normalizes messages with _getType()', () => { |
@@ -246,3 +250,88 @@ describe('extractChatModelRequestAttributes with multimodal content', () => { |
246 | 250 | expect(inputMessages).toContain('What is in this image?'); |
247 | 251 | }); |
248 | 252 | }); |
| 253 | + |
| 254 | +describe('_INTERNAL_mergeLangChainCallbackHandler', () => { |
| 255 | + const sentryHandler = { name: 'SentryCallbackHandler' }; |
| 256 | + |
| 257 | + function makeFakeCallbackManager(existingHandlers: unknown[] = []) { |
| 258 | + const manager = { |
| 259 | + handlers: [...existingHandlers], |
| 260 | + inheritableHandlers: [...existingHandlers], |
| 261 | + addHandler: vi.fn(function (this: any, handler: unknown, inherit?: boolean) { |
| 262 | + this.handlers.push(handler); |
| 263 | + if (inherit !== false) { |
| 264 | + this.inheritableHandlers.push(handler); |
| 265 | + } |
| 266 | + }), |
| 267 | + copy: vi.fn(function (this: any) { |
| 268 | + return makeFakeCallbackManager(this.handlers); |
| 269 | + }), |
| 270 | + }; |
| 271 | + return manager; |
| 272 | + } |
| 273 | + |
| 274 | + it('returns a fresh array when no existing callbacks are present', () => { |
| 275 | + expect(_INTERNAL_mergeLangChainCallbackHandler(undefined, sentryHandler)).toStrictEqual([sentryHandler]); |
| 276 | + expect(_INTERNAL_mergeLangChainCallbackHandler(null, sentryHandler)).toStrictEqual([sentryHandler]); |
| 277 | + }); |
| 278 | + |
| 279 | + it('appends to an existing callbacks array', () => { |
| 280 | + const userA = { _user: 'A' }; |
| 281 | + const userB = { _user: 'B' }; |
| 282 | + expect(_INTERNAL_mergeLangChainCallbackHandler([userA, userB], sentryHandler)).toStrictEqual([ |
| 283 | + userA, |
| 284 | + userB, |
| 285 | + sentryHandler, |
| 286 | + ]); |
| 287 | + }); |
| 288 | + |
| 289 | + it('does not duplicate when the sentry handler is already in the array', () => { |
| 290 | + const userA = { _user: 'A' }; |
| 291 | + const existing = [userA, sentryHandler]; |
| 292 | + expect(_INTERNAL_mergeLangChainCallbackHandler(existing, sentryHandler)).toBe(existing); |
| 293 | + }); |
| 294 | + |
| 295 | + it('preserves inheritable handlers when callbacks is a CallbackManager', () => { |
| 296 | + // Reproduces the LangGraph `streamMode: ['messages']` setup: a |
| 297 | + // CallbackManager carrying a StreamMessagesHandler is passed via |
| 298 | + // options.callbacks. Wrapping it as `[manager, sentryHandler]` would |
| 299 | + // drop the manager's inheritable children — instead we register |
| 300 | + // Sentry on a copy and keep the existing handler chain intact. |
| 301 | + const streamMessagesHandler = { name: 'StreamMessagesHandler', lc_prefer_streaming: true }; |
| 302 | + const manager = makeFakeCallbackManager([streamMessagesHandler]); |
| 303 | + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler) as { handlers: unknown[] }; |
| 304 | + expect(Array.isArray(result)).toBe(false); |
| 305 | + expect(result.handlers).toEqual([streamMessagesHandler, sentryHandler]); |
| 306 | + }); |
| 307 | + |
| 308 | + it('copies the manager and registers Sentry as an inheritable handler', () => { |
| 309 | + const manager = makeFakeCallbackManager([]); |
| 310 | + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler) as { |
| 311 | + addHandler: ReturnType<typeof vi.fn>; |
| 312 | + inheritableHandlers: unknown[]; |
| 313 | + }; |
| 314 | + expect(manager.copy).toHaveBeenCalledTimes(1); |
| 315 | + expect(manager.handlers).toEqual([]); |
| 316 | + expect(result.addHandler).toHaveBeenCalledWith(sentryHandler, true); |
| 317 | + expect(result.inheritableHandlers).toEqual([sentryHandler]); |
| 318 | + }); |
| 319 | + |
| 320 | + it('returns the manager unchanged without copying when it already contains the handler', () => { |
| 321 | + const manager = makeFakeCallbackManager([sentryHandler]); |
| 322 | + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler); |
| 323 | + expect(result).toBe(manager); |
| 324 | + expect(manager.copy).not.toHaveBeenCalled(); |
| 325 | + expect(manager.addHandler).not.toHaveBeenCalled(); |
| 326 | + }); |
| 327 | + |
| 328 | + it('wraps a lone callback object into an array with the sentry handler', () => { |
| 329 | + const opaque = { name: 'NotAManager' }; |
| 330 | + expect(_INTERNAL_mergeLangChainCallbackHandler(opaque, sentryHandler)).toStrictEqual([opaque, sentryHandler]); |
| 331 | + }); |
| 332 | + |
| 333 | + it('returns unchanged when the lone callback object is already a sentry handler', () => { |
| 334 | + const existing = { name: 'SentryCallbackHandler' }; |
| 335 | + expect(_INTERNAL_mergeLangChainCallbackHandler(existing, sentryHandler)).toBe(existing); |
| 336 | + }); |
| 337 | +}); |
0 commit comments