Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e2b872a
fix(node): Preserve CallbackManager handlers in LangChain instrumenta…
mdnanocom May 12, 2026
4c74bfa
test(node): Add unit tests + changelog for LangChain callback fix
mdnanocom May 12, 2026
b33352b
fix(node): Defensively check inheritableHandlers in LangChain dedupe
mdnanocom May 12, 2026
9e2939d
refactor(core): Consolidate LangChain callback merging into mergeSent…
mdnanocom May 12, 2026
7dcdd93
review: Tighten CallbackManager detection + drop changelog entry
mdnanocom May 13, 2026
fb95ad5
Merge remote-tracking branch 'origin/develop' into fix/langchain-call…
mdnanocom May 13, 2026
5657854
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 13, 2026
7333d66
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 14, 2026
d88c503
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 18, 2026
5e5627a
review: Address andreiborza review on PR #20849
mdnanocom May 20, 2026
cde3b58
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 20, 2026
6f36bd1
review: Skip copy when manager already contains the sentry handler
mdnanocom May 21, 2026
6bb5463
fix(langchain): Use renamed `_INTERNAL_mergeLangChainCallbackHandler`
mdnanocom May 21, 2026
745b9e1
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 21, 2026
315373d
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 21, 2026
45b7a05
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 21, 2026
926b4bc
Simplify logic
andreiborza May 22, 2026
2704ffd
Merge branch 'develop' into fix/langchain-callbackmanager-preservation
mdnanocom May 22, 2026
ea874b9
Check for existing SentryCallbackHandler via name property instead of…
andreiborza May 22, 2026
fb9f575
Ensure lone handlers are handled correctly
andreiborza May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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';
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/tracing/langchain/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,51 @@ export function extractToolDefinitions(extraParams?: Record<string, unknown>): 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';
}

function isSentryHandler(handler: unknown): boolean {
return typeof handler === 'object' && (handler as Record<string, unknown>)?.name === 'SentryCallbackHandler';
}

function containsSentryHandler(handlers: unknown[]): boolean {
return handlers.some(isSentryHandler);
}

/**
* 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 (isCallbackManager(existing)) {
if (containsSentryHandler(existing.handlers ?? [])) {
return existing;
}

const copied = existing.copy() as { addHandler: (handler: unknown, inherit?: boolean) => void };
copied.addHandler(sentryHandler, true);
return copied;
}

const handlers = Array.isArray(existing) ? existing : [existing];
if (containsSentryHandler(handlers)) {
return existing;
}

return [...handlers, sentryHandler];
}
7 changes: 5 additions & 2 deletions packages/core/src/tracing/langgraph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ import {
extractAgentNameFromParams,
extractLLMFromParams,
extractToolsFromCompiledGraph,
mergeSentryCallback,
setResponseAttributes,
wrapToolsWithSpans,
} from './utils';
import { _INTERNAL_mergeLangChainCallbackHandler } from '../langchain/utils';

let _insideCreateReactAgent = false;

Expand Down Expand Up @@ -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
Expand Down
24 changes: 0 additions & 24 deletions packages/core/src/tracing/langgraph/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
93 changes: 91 additions & 2 deletions packages/core/test/lib/tracing/langchain-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()', () => {
Expand Down Expand Up @@ -246,3 +250,88 @@ describe('extractChatModelRequestAttributes with multimodal content', () => {
expect(inputMessages).toContain('What is in this image?');
});
});

describe('_INTERNAL_mergeLangChainCallbackHandler', () => {
const sentryHandler = { name: 'SentryCallbackHandler' };

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<typeof vi.fn>;
inheritableHandlers: unknown[];
};
expect(manager.copy).toHaveBeenCalledTimes(1);
expect(manager.handlers).toEqual([]);
expect(result.addHandler).toHaveBeenCalledWith(sentryHandler, true);
expect(result.inheritableHandlers).toEqual([sentryHandler]);
});

it('returns the manager unchanged without copying when it already contains the handler', () => {
const manager = makeFakeCallbackManager([sentryHandler]);
const result = _INTERNAL_mergeLangChainCallbackHandler(manager, sentryHandler);
expect(result).toBe(manager);
expect(manager.copy).not.toHaveBeenCalled();
expect(manager.addHandler).not.toHaveBeenCalled();
});

it('wraps a lone callback object into an array with the sentry handler', () => {
const opaque = { name: 'NotAManager' };
expect(_INTERNAL_mergeLangChainCallbackHandler(opaque, sentryHandler)).toStrictEqual([opaque, sentryHandler]);
});

it('returns unchanged when the lone callback object is already a sentry handler', () => {
const existing = { name: 'SentryCallbackHandler' };
expect(_INTERNAL_mergeLangChainCallbackHandler(existing, sentryHandler)).toBe(existing);
});
});
45 changes: 2 additions & 43 deletions packages/core/test/lib/utils/langgraph-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@opentelemetry/instrumentation';
import type { LangChainOptions } from '@sentry/core';
import {
_INTERNAL_mergeLangChainCallbackHandler,
_INTERNAL_skipAiProviderWrapping,
ANTHROPIC_AI_INTEGRATION_NAME,
createLangChainCallbackHandler,
Expand All @@ -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 {
Comment thread
mdnanocom marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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 = _INTERNAL_mergeLangChainCallbackHandler(options.callbacks, sentryHandler);

// Call original method with augmented options
return Reflect.apply(target, thisArg, args);
Expand Down
Loading