Skip to content

Commit 0c2c666

Browse files
committed
refactor(core): Consolidate LangChain callback merging into mergeSentryCallback
The langchain instrumentation's `augmentCallbackHandlers` and the langgraph instrumentation's `mergeSentryCallback` solved the same problem two different ways. The langgraph helper had three latent bugs that this PR's fix already covered for langchain: - mutated the caller's CallbackManager (handlers accumulate across runs) - called `addHandler(handler)` without `inherit=true`, so the handler never propagated to child managers created by `getChild` - deduped only against `manager.handlers`, not `inheritableHandlers` Lift the fixed implementation up to `@sentry/core` so both instrumentations share it. The langgraph integration silently picks up the streaming fix as a bonus. Drops the duplicate `augmentCallback` helper and its test; behavior is covered by the expanded `mergeSentryCallback` suite (14 cases).
1 parent b33352b commit 0c2c666

5 files changed

Lines changed: 147 additions & 212 deletions

File tree

packages/core/src/shared-exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from '.
173173
export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants';
174174
export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types';
175175
export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph';
176+
export { mergeSentryCallback } from './tracing/langgraph/utils';
176177
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
177178
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
178179
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';

packages/core/src/tracing/langgraph/utils.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,44 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag
335335
}
336336
}
337337

338-
/** Merge `sentryHandler` into a langchain `callbacks` value (`BaseCallbackHandler[]` or `BaseCallbackManager`). */
338+
/**
339+
* Duck-types a LangChain `CallbackManager` instance. We can't `instanceof`
340+
* check because `@langchain/core` may be bundled, deduped, or absent from
341+
* our module graph; checking the shape avoids that coupling.
342+
*/
343+
function isCallbackManager(value: unknown): value is {
344+
addHandler: (handler: unknown, inherit?: boolean) => void;
345+
copy: () => unknown;
346+
handlers?: unknown[];
347+
inheritableHandlers?: unknown[];
348+
} {
349+
if (!value || typeof value !== 'object') {
350+
return false;
351+
}
352+
const candidate = value as { addHandler?: unknown; copy?: unknown };
353+
return typeof candidate.addHandler === 'function' && typeof candidate.copy === 'function';
354+
}
355+
356+
/**
357+
* Merge `sentryHandler` into a langchain `callbacks` value.
358+
*
359+
* Accepts the three shapes LangChain's `RunnableConfig.callbacks` can take:
360+
* - `undefined` → wrap our handler in a fresh array
361+
* - `BaseCallbackHandler[]` → append (idempotent)
362+
* - `BaseCallbackManager` → `.copy()` and register as an inheritable child
363+
*
364+
* The `CallbackManager` case is load-bearing: when LangGraph sets up a run
365+
* with `streamMode: ['messages']`, it puts a `StreamMessagesHandler` onto a
366+
* `CallbackManager` and passes that manager through `options.callbacks`. If
367+
* we naively wrap the manager into `[manager, sentryHandler]`, LangChain
368+
* downstream treats the whole manager as a single opaque handler — its
369+
* inheritable children (`StreamMessagesHandler`, the tracer, etc.) are
370+
* never unpacked, and per-token streaming events silently disappear.
371+
*
372+
* We always operate on a copy so repeated invocations don't accumulate
373+
* handlers on the caller's manager, and we add ourselves as inheritable
374+
* so child callback managers created by `getChild` see us too.
375+
*/
339376
export function mergeSentryCallback(existing: unknown, sentryHandler: unknown): unknown {
340377
if (!existing) {
341378
return [sentryHandler];
@@ -348,12 +385,23 @@ export function mergeSentryCallback(existing: unknown, sentryHandler: unknown):
348385
return [...existing, sentryHandler];
349386
}
350387

351-
const manager = existing as { addHandler?: (h: unknown) => void; handlers?: unknown[] };
352-
if (typeof manager.addHandler === 'function') {
353-
const alreadyAdded = Array.isArray(manager.handlers) && manager.handlers.includes(sentryHandler);
354-
if (!alreadyAdded) {
355-
manager.addHandler(sentryHandler);
388+
if (isCallbackManager(existing)) {
389+
const copied = existing.copy() as {
390+
addHandler: (handler: unknown, inherit?: boolean) => void;
391+
handlers?: unknown[];
392+
inheritableHandlers?: unknown[];
393+
};
394+
// CallbackManager keeps `inheritableHandlers ⊆ handlers` (both
395+
// `addHandler` and `setHandlers` maintain the invariant), so checking
396+
// `handlers` alone normally suffices — we check both as a defensive
397+
// guard against externally-constructed managers that bypass `addHandler`.
398+
const alreadyRegistered =
399+
(copied.handlers?.includes(sentryHandler) ?? false) ||
400+
(copied.inheritableHandlers?.includes(sentryHandler) ?? false);
401+
if (!alreadyRegistered) {
402+
copied.addHandler(sentryHandler, true);
356403
}
404+
return copied;
357405
}
358406

359407
return existing;

packages/core/test/lib/utils/langgraph-utils.test.ts

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ describe('extractAgentNameFromParams', () => {
4848
describe('mergeSentryCallback', () => {
4949
const sentryHandler = { _sentry: true };
5050

51+
/**
52+
* Minimal `CallbackManager` stand-in. Mirrors `@langchain/core`'s real
53+
* semantics: `addHandler(_, inherit)` pushes to both `handlers` and
54+
* `inheritableHandlers` when `inherit !== false`, and `copy()` returns
55+
* a fresh manager carrying the same handlers — so we don't accidentally
56+
* test against a degenerate shape that bypasses `addHandler`.
57+
*/
58+
function makeFakeCallbackManager(existingHandlers: unknown[] = [], existingInheritableHandlers?: unknown[]) {
59+
const manager = {
60+
handlers: [...existingHandlers],
61+
inheritableHandlers: [...(existingInheritableHandlers ?? existingHandlers)],
62+
addHandler: vi.fn(function (this: any, handler: unknown, inherit?: boolean) {
63+
this.handlers.push(handler);
64+
if (inherit !== false) {
65+
this.inheritableHandlers.push(handler);
66+
}
67+
}),
68+
copy: vi.fn(function (this: any) {
69+
return makeFakeCallbackManager(this.handlers, this.inheritableHandlers);
70+
}),
71+
};
72+
return manager;
73+
}
74+
5175
it('returns a fresh array when no existing callbacks are present', () => {
5276
expect(mergeSentryCallback(undefined, sentryHandler)).toStrictEqual([sentryHandler]);
5377
expect(mergeSentryCallback(null, sentryHandler)).toStrictEqual([sentryHandler]);
@@ -65,19 +89,74 @@ describe('mergeSentryCallback', () => {
6589
expect(mergeSentryCallback(existing, sentryHandler)).toBe(existing);
6690
});
6791

68-
it('calls addHandler on a CallbackManager-like object', () => {
69-
const addHandler = vi.fn();
70-
const manager = { addHandler, handlers: [] as unknown[] };
71-
const result = mergeSentryCallback(manager, sentryHandler);
72-
expect(result).toBe(manager);
73-
expect(addHandler).toHaveBeenCalledWith(sentryHandler);
74-
expect(addHandler).toHaveBeenCalledTimes(1);
92+
it('preserves inheritable handlers when callbacks is a CallbackManager', () => {
93+
// Reproduces the LangGraph `streamMode: ['messages']` setup: a
94+
// CallbackManager carrying a StreamMessagesHandler is passed via
95+
// options.callbacks. Wrapping it as `[manager, sentryHandler]` would
96+
// drop the manager's inheritable children — instead we register
97+
// Sentry on a copy and keep the existing handler chain intact.
98+
const streamMessagesHandler = {
99+
name: 'StreamMessagesHandler',
100+
lc_prefer_streaming: true,
101+
};
102+
const manager = makeFakeCallbackManager([streamMessagesHandler]);
103+
const result = mergeSentryCallback(manager, sentryHandler) as {
104+
handlers: unknown[];
105+
};
106+
expect(Array.isArray(result)).toBe(false);
107+
expect(result.handlers).toEqual([streamMessagesHandler, sentryHandler]);
75108
});
76109

77-
it('does not re-add when the manager already has the sentry handler', () => {
78-
const addHandler = vi.fn();
79-
const manager = { addHandler, handlers: [sentryHandler] };
110+
it('copies the manager rather than mutating the caller-supplied one', () => {
111+
// If we mutated the original, repeated invocations would accumulate
112+
// Sentry handlers (and tracers from prior runs would leak across runs).
113+
const manager = makeFakeCallbackManager([]);
80114
mergeSentryCallback(manager, sentryHandler);
81-
expect(addHandler).not.toHaveBeenCalled();
115+
expect(manager.copy).toHaveBeenCalledTimes(1);
116+
expect(manager.handlers).toEqual([]);
117+
});
118+
119+
it('registers the sentry handler as inheritable so child managers see it', () => {
120+
// LangChain's CallbackManager.getChild creates child managers via
121+
// `setHandlers(this.inheritableHandlers)`. If we add ourselves without
122+
// `inherit=true`, nested LLM calls inside an agent never receive the
123+
// Sentry handler.
124+
const manager = makeFakeCallbackManager([]);
125+
const result = mergeSentryCallback(manager, sentryHandler) as {
126+
addHandler: ReturnType<typeof vi.fn>;
127+
handlers: unknown[];
128+
inheritableHandlers: unknown[];
129+
};
130+
expect(result.addHandler).toHaveBeenCalledWith(sentryHandler, true);
131+
expect(result.inheritableHandlers).toEqual([sentryHandler]);
132+
});
133+
134+
it('does not double-register when the copied manager already contains the handler', () => {
135+
const manager = makeFakeCallbackManager([sentryHandler]);
136+
const result = mergeSentryCallback(manager, sentryHandler) as {
137+
handlers: unknown[];
138+
addHandler: ReturnType<typeof vi.fn>;
139+
};
140+
expect(result.handlers).toEqual([sentryHandler]);
141+
expect(result.addHandler).not.toHaveBeenCalled();
142+
});
143+
144+
it('does not double-register when the handler lives only on inheritableHandlers', () => {
145+
// Defensive: a CallbackManager subclass or externally-constructed
146+
// instance might keep the Sentry handler on `inheritableHandlers`
147+
// without mirroring it onto `handlers`. We must still recognize it
148+
// as already-registered to avoid duplicate spans on nested calls.
149+
const manager = makeFakeCallbackManager([], [sentryHandler]);
150+
const result = mergeSentryCallback(manager, sentryHandler) as {
151+
addHandler: ReturnType<typeof vi.fn>;
152+
inheritableHandlers: unknown[];
153+
};
154+
expect(result.addHandler).not.toHaveBeenCalled();
155+
expect(result.inheritableHandlers).toEqual([sentryHandler]);
156+
});
157+
158+
it('returns the value unchanged when it is neither an array nor a CallbackManager', () => {
159+
const opaque = { name: 'NotAManager' };
160+
expect(mergeSentryCallback(opaque, sentryHandler)).toBe(opaque);
82161
});
83162
});

packages/node/src/integrations/tracing/langchain/instrumentation.ts

Lines changed: 2 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createLangChainCallbackHandler,
1313
GOOGLE_GENAI_INTEGRATION_NAME,
1414
instrumentLangChainEmbeddings,
15+
mergeSentryCallback,
1516
OPENAI_INTEGRATION_NAME,
1617
SDK_VERSION,
1718
} from '@sentry/core';
@@ -27,92 +28,6 @@ interface PatchedLangChainExports {
2728
[key: string]: unknown;
2829
}
2930

30-
/**
31-
* Duck-types a LangChain `CallbackManager` instance. We can't `instanceof`
32-
* check because `@langchain/core` may be bundled, deduped, or absent from
33-
* our module graph; checking the shape avoids that coupling.
34-
*/
35-
function isCallbackManager(value: unknown): value is {
36-
addHandler: (handler: unknown, inherit?: boolean) => void;
37-
copy: () => unknown;
38-
handlers?: unknown[];
39-
} {
40-
if (!value || typeof value !== 'object') {
41-
return false;
42-
}
43-
const candidate = value as { addHandler?: unknown; copy?: unknown };
44-
return typeof candidate.addHandler === 'function' && typeof candidate.copy === 'function';
45-
}
46-
47-
/**
48-
* Exported for testing.
49-
* @internal
50-
*/
51-
export const _INTERNAL_augmentCallbackHandlers = augmentCallbackHandlers;
52-
53-
/**
54-
* Augments a callback handler list with Sentry's handler if not already present.
55-
*
56-
* `options.callbacks` may be one of three shapes (per LangChain's RunnableConfig):
57-
* - `undefined` → no callbacks configured
58-
* - `BaseCallbackHandler[]` → list of handler instances
59-
* - `CallbackManager` → a manager that already holds (potentially
60-
* inheritable) child handlers
61-
*
62-
* The `CallbackManager` case is the load-bearing one: when LangGraph sets up
63-
* a run with `streamMode: ['messages']`, it puts a `StreamMessagesHandler`
64-
* onto a `CallbackManager` and passes that manager through `options.callbacks`.
65-
* If we naively wrap the manager into `[manager, sentryHandler]`, LangChain
66-
* downstream treats the whole manager as a single opaque handler — its
67-
* inheritable children (`StreamMessagesHandler`, the tracer, etc.) are never
68-
* unpacked, and per-token streaming events silently disappear.
69-
*
70-
* Instead, when we receive a `CallbackManager`, we copy it (so we don't
71-
* mutate the caller's manager across invocations) and register Sentry's
72-
* handler as an inheritable child via `.addHandler()`.
73-
*/
74-
function augmentCallbackHandlers(handlers: unknown, sentryHandler: unknown): unknown {
75-
// Handle null/undefined - return array with just our handler
76-
if (!handlers) {
77-
return [sentryHandler];
78-
}
79-
80-
// If handlers is already an array
81-
if (Array.isArray(handlers)) {
82-
// Check if our handler is already in the list
83-
if (handlers.includes(sentryHandler)) {
84-
return handlers;
85-
}
86-
// Add our handler to the list
87-
return [...handlers, sentryHandler];
88-
}
89-
90-
// CallbackManager: register our handler as an inheritable child on a copy
91-
// so we preserve any handlers already attached (notably LangGraph's
92-
// StreamMessagesHandler used by `streamMode: ['messages']`).
93-
if (isCallbackManager(handlers)) {
94-
const copied = handlers.copy() as {
95-
addHandler: (handler: unknown, inherit?: boolean) => void;
96-
handlers?: unknown[];
97-
inheritableHandlers?: unknown[];
98-
};
99-
// Avoid double-registering on nested invocations. CallbackManager's own
100-
// `addHandler` keeps `inheritableHandlers ⊆ handlers`, so checking
101-
// `handlers` alone is normally enough — but we check both as a defensive
102-
// guard against externally-constructed managers that bypass `addHandler`.
103-
const alreadyRegistered =
104-
(copied.handlers?.includes(sentryHandler) ?? false) ||
105-
(copied.inheritableHandlers?.includes(sentryHandler) ?? false);
106-
if (!alreadyRegistered) {
107-
copied.addHandler(sentryHandler, true);
108-
}
109-
return copied;
110-
}
111-
112-
// Unknown type - return original
113-
return handlers;
114-
}
115-
11631
/**
11732
* Wraps Runnable methods (invoke, stream, batch) to inject Sentry callbacks at request time
11833
* Uses a Proxy to intercept method calls and augment the options.callbacks
@@ -140,9 +55,7 @@ function wrapRunnableMethod(
14055
}
14156

14257
// Inject our callback handler into options.callbacks (request time callbacks)
143-
const existingCallbacks = options.callbacks;
144-
const augmentedCallbacks = augmentCallbackHandlers(existingCallbacks, sentryHandler);
145-
options.callbacks = augmentedCallbacks;
58+
options.callbacks = mergeSentryCallback(options.callbacks, sentryHandler);
14659

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

0 commit comments

Comments
 (0)