diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index fa5e490f8b18..c3f8ff38131b 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -171,6 +171,7 @@ export { instrumentGoogleGenAIClient } from './tracing/google-genai'; export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants'; export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain'; +export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph'; diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 24f551047f87..a76c5a8aa100 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -537,3 +537,46 @@ export function extractToolDefinitions(extraParams?: Record): s }); return JSON.stringify(toolDefs); } + +/** Duck-types a LangChain `CallbackManager` (avoids coupling to a specific `@langchain/core` resolution). */ +function isCallbackManager(value: unknown): value is { + addHandler: (handler: unknown, inherit?: boolean) => void; + copy: () => unknown; + handlers?: unknown[]; +} { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as { addHandler?: unknown; copy?: unknown }; + return typeof candidate.addHandler === 'function' && typeof candidate.copy === 'function'; +} + +/** + * Merge `sentryHandler` into a given set of LangChain callbacks or callback manager. + * @internal Exported for cross-package instrumentation. + */ +export function _INTERNAL_mergeLangChainCallbackHandler(existing: unknown, sentryHandler: unknown): unknown { + if (!existing) { + return [sentryHandler]; + } + + if (Array.isArray(existing)) { + if (existing.includes(sentryHandler)) { + return existing; + } + return [...existing, sentryHandler]; + } + + if (isCallbackManager(existing)) { + const copied = existing.copy() as { + addHandler: (handler: unknown, inherit?: boolean) => void; + handlers?: unknown[]; + }; + if (!copied.handlers?.includes(sentryHandler)) { + copied.addHandler(sentryHandler, true); + } + return copied; + } + + return existing; +} diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d43159a62ee1..eaa4d719323e 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -30,10 +30,10 @@ import { extractAgentNameFromParams, extractLLMFromParams, extractToolsFromCompiledGraph, - mergeSentryCallback, setResponseAttributes, wrapToolsWithSpans, } from './utils'; +import { _INTERNAL_mergeLangChainCallbackHandler } from '../langchain/utils'; let _insideCreateReactAgent = false; @@ -179,7 +179,10 @@ function instrumentCompiledGraphInvoke( ...(typeof graphName === 'string' ? { lc_agent_name: graphName } : {}), }; - invokeConfig.callbacks = mergeSentryCallback(invokeConfig.callbacks, sentryCallbackHandler); + invokeConfig.callbacks = _INTERNAL_mergeLangChainCallbackHandler( + invokeConfig.callbacks, + sentryCallbackHandler, + ); } // Extract available tools from the graph instance diff --git a/packages/core/src/tracing/langgraph/utils.ts b/packages/core/src/tracing/langgraph/utils.ts index de4d559769ca..cf37ce18056e 100644 --- a/packages/core/src/tracing/langgraph/utils.ts +++ b/packages/core/src/tracing/langgraph/utils.ts @@ -334,27 +334,3 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, totalTokens); } } - -/** Merge `sentryHandler` into a langchain `callbacks` value (`BaseCallbackHandler[]` or `BaseCallbackManager`). */ -export function mergeSentryCallback(existing: unknown, sentryHandler: unknown): unknown { - if (!existing) { - return [sentryHandler]; - } - - if (Array.isArray(existing)) { - if (existing.includes(sentryHandler)) { - return existing; - } - return [...existing, sentryHandler]; - } - - const manager = existing as { addHandler?: (h: unknown) => void; handlers?: unknown[] }; - if (typeof manager.addHandler === 'function') { - const alreadyAdded = Array.isArray(manager.handlers) && manager.handlers.includes(sentryHandler); - if (!alreadyAdded) { - manager.addHandler(sentryHandler); - } - } - - return existing; -} diff --git a/packages/core/test/lib/tracing/langchain-utils.test.ts b/packages/core/test/lib/tracing/langchain-utils.test.ts index 18807631c404..8cc69090dec6 100644 --- a/packages/core/test/lib/tracing/langchain-utils.test.ts +++ b/packages/core/test/lib/tracing/langchain-utils.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE } from '../../../src/tracing/ai/gen-ai-attributes'; import type { LangChainMessage } from '../../../src/tracing/langchain/types'; -import { extractChatModelRequestAttributes, normalizeLangChainMessages } from '../../../src/tracing/langchain/utils'; +import { + _INTERNAL_mergeLangChainCallbackHandler, + extractChatModelRequestAttributes, + normalizeLangChainMessages, +} from '../../../src/tracing/langchain/utils'; describe('normalizeLangChainMessages', () => { it('normalizes messages with _getType()', () => { @@ -246,3 +250,85 @@ describe('extractChatModelRequestAttributes with multimodal content', () => { expect(inputMessages).toContain('What is in this image?'); }); }); + +describe('_INTERNAL_mergeLangChainCallbackHandler', () => { + const sentryHandler = { _sentry: true }; + + function makeFakeCallbackManager(existingHandlers: unknown[] = []) { + const manager = { + handlers: [...existingHandlers], + inheritableHandlers: [...existingHandlers], + addHandler: vi.fn(function (this: any, handler: unknown, inherit?: boolean) { + this.handlers.push(handler); + if (inherit !== false) { + this.inheritableHandlers.push(handler); + } + }), + copy: vi.fn(function (this: any) { + return makeFakeCallbackManager(this.handlers); + }), + }; + return manager; + } + + it('returns a fresh array when no existing callbacks are present', () => { + expect(_INTERNAL_mergeLangChainCallbackHandler(undefined, sentryHandler)).toStrictEqual([sentryHandler]); + expect(_INTERNAL_mergeLangChainCallbackHandler(null, sentryHandler)).toStrictEqual([sentryHandler]); + }); + + it('appends to an existing callbacks array', () => { + const userA = { _user: 'A' }; + const userB = { _user: 'B' }; + expect(_INTERNAL_mergeLangChainCallbackHandler([userA, userB], sentryHandler)).toStrictEqual([ + userA, + userB, + sentryHandler, + ]); + }); + + it('does not duplicate when the sentry handler is already in the array', () => { + const userA = { _user: 'A' }; + const existing = [userA, sentryHandler]; + expect(_INTERNAL_mergeLangChainCallbackHandler(existing, sentryHandler)).toBe(existing); + }); + + it('preserves inheritable handlers when callbacks is a CallbackManager', () => { + // Reproduces the LangGraph `streamMode: ['messages']` setup: a + // CallbackManager carrying a StreamMessagesHandler is passed via + // options.callbacks. Wrapping it as `[manager, sentryHandler]` would + // drop the manager's inheritable children — instead we register + // Sentry on a copy and keep the existing handler chain intact. + const streamMessagesHandler = { name: 'StreamMessagesHandler', lc_prefer_streaming: true }; + const manager = makeFakeCallbackManager([streamMessagesHandler]); + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler) as { handlers: unknown[] }; + expect(Array.isArray(result)).toBe(false); + expect(result.handlers).toEqual([streamMessagesHandler, sentryHandler]); + }); + + it('copies the manager and registers Sentry as an inheritable handler', () => { + const manager = makeFakeCallbackManager([]); + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler) as { + addHandler: ReturnType; + inheritableHandlers: unknown[]; + }; + expect(manager.copy).toHaveBeenCalledTimes(1); + expect(manager.handlers).toEqual([]); + expect(result.addHandler).toHaveBeenCalledWith(sentryHandler, true); + expect(result.inheritableHandlers).toEqual([sentryHandler]); + }); + + it('does not double-register when the copied manager already contains the handler', () => { + const manager = makeFakeCallbackManager([sentryHandler]); + const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler) as { + handlers: unknown[]; + addHandler: ReturnType; + }; + expect(result.handlers).toEqual([sentryHandler]); + expect(result.addHandler).not.toHaveBeenCalled(); + }); + + it('returns the value unchanged when it is neither an array nor a CallbackManager', () => { + const opaque = { name: 'NotAManager' }; + expect(_INTERNAL_mergeLangChainCallbackHandler(opaque, sentryHandler)).toBe(opaque); + }); +}); diff --git a/packages/core/test/lib/utils/langgraph-utils.test.ts b/packages/core/test/lib/utils/langgraph-utils.test.ts index 829317518622..b5943784b282 100644 --- a/packages/core/test/lib/utils/langgraph-utils.test.ts +++ b/packages/core/test/lib/utils/langgraph-utils.test.ts @@ -1,9 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - extractAgentNameFromParams, - extractLLMFromParams, - mergeSentryCallback, -} from '../../../src/tracing/langgraph/utils'; +import { describe, expect, it } from 'vitest'; +import { extractAgentNameFromParams, extractLLMFromParams } from '../../../src/tracing/langgraph/utils'; describe('extractLLMFromParams', () => { it('returns null for empty or invalid args', () => { @@ -44,40 +40,3 @@ describe('extractAgentNameFromParams', () => { expect(extractAgentNameFromParams([{ name: 'my_agent' }])).toBe('my_agent'); }); }); - -describe('mergeSentryCallback', () => { - const sentryHandler = { _sentry: true }; - - it('returns a fresh array when no existing callbacks are present', () => { - expect(mergeSentryCallback(undefined, sentryHandler)).toStrictEqual([sentryHandler]); - expect(mergeSentryCallback(null, sentryHandler)).toStrictEqual([sentryHandler]); - }); - - it('appends to an existing callbacks array', () => { - const userA = { _user: 'A' }; - const userB = { _user: 'B' }; - expect(mergeSentryCallback([userA, userB], sentryHandler)).toStrictEqual([userA, userB, sentryHandler]); - }); - - it('does not duplicate when the sentry handler is already in the array', () => { - const userA = { _user: 'A' }; - const existing = [userA, sentryHandler]; - expect(mergeSentryCallback(existing, sentryHandler)).toBe(existing); - }); - - it('calls addHandler on a CallbackManager-like object', () => { - const addHandler = vi.fn(); - const manager = { addHandler, handlers: [] as unknown[] }; - const result = mergeSentryCallback(manager, sentryHandler); - expect(result).toBe(manager); - expect(addHandler).toHaveBeenCalledWith(sentryHandler); - expect(addHandler).toHaveBeenCalledTimes(1); - }); - - it('does not re-add when the manager already has the sentry handler', () => { - const addHandler = vi.fn(); - const manager = { addHandler, handlers: [sentryHandler] }; - mergeSentryCallback(manager, sentryHandler); - expect(addHandler).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts index fb3e80b48583..6fc32ec685e4 100644 --- a/packages/node/src/integrations/tracing/langchain/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -7,6 +7,7 @@ import { } from '@opentelemetry/instrumentation'; import type { LangChainOptions } from '@sentry/core'; import { + _INTERNAL_mergeLangChainCallbackHandler, _INTERNAL_skipAiProviderWrapping, ANTHROPIC_AI_INTEGRATION_NAME, createLangChainCallbackHandler, @@ -27,34 +28,6 @@ interface PatchedLangChainExports { [key: string]: unknown; } -/** - * Augments a callback handler list with Sentry's handler if not already present - */ -function augmentCallbackHandlers(handlers: unknown, sentryHandler: unknown): unknown { - // Handle null/undefined - return array with just our handler - if (!handlers) { - return [sentryHandler]; - } - - // If handlers is already an array - if (Array.isArray(handlers)) { - // Check if our handler is already in the list - if (handlers.includes(sentryHandler)) { - return handlers; - } - // Add our handler to the list - return [...handlers, sentryHandler]; - } - - // If it's a single handler object, convert to array - if (typeof handlers === 'object') { - return [handlers, sentryHandler]; - } - - // Unknown type - return original - return handlers; -} - /** * Wraps Runnable methods (invoke, stream, batch) to inject Sentry callbacks at request time * Uses a Proxy to intercept method calls and augment the options.callbacks @@ -82,9 +55,7 @@ function wrapRunnableMethod( } // Inject our callback handler into options.callbacks (request time callbacks) - const existingCallbacks = options.callbacks; - const augmentedCallbacks = augmentCallbackHandlers(existingCallbacks, sentryHandler); - options.callbacks = augmentedCallbacks; + options.callbacks = mergeSentryCallback(options.callbacks, sentryHandler); // Call original method with augmented options return Reflect.apply(target, thisArg, args);