Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -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';
Expand Down
43 changes: 43 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,46 @@ 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';
}

/**
* 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;
Comment on lines +574 to +578
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: We can make a small improvement here by only copying if the existing.handlers does not include the sentry handler already. That way we don't make a copy unless we really need to.

Copy link
Copy Markdown
Author

@mdnanocom mdnanocom May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

return existing;
}
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;
}
90 changes: 88 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,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<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('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<typeof vi.fn>;
};
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);
});
});
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 = mergeSentryCallback(options.callbacks, sentryHandler);
Comment thread
mdnanocom marked this conversation as resolved.
Outdated

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