Skip to content

Commit abacdbf

Browse files
authored
feat(server-utils): Rewrite SentryLangGraphInstrumentation to orchestrion (#22268)
Rewrites the LangGraph integration to a `node:diagnostics_channel` listener instead of `InstrumentationBase`, with orchestrion injecting the channels. The vendored OTel path stays as the fallback when orchestrion isn't injected. closes #20916
1 parent e0865f9 commit abacdbf

5 files changed

Lines changed: 268 additions & 49 deletions

File tree

packages/core/src/shared-exports.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,14 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from '.
219219
export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils';
220220
export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants';
221221
export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types';
222-
export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph';
222+
export {
223+
instrumentStateGraphCompile,
224+
instrumentCreateReactAgent,
225+
instrumentLangGraph,
226+
instrumentCompiledGraphInvoke,
227+
_INTERNAL_getLangGraphCreateAgentSpanOptions,
228+
} from './tracing/langgraph';
229+
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
223230
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
224231
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
225232
// eslint-disable-next-line typescript/no-deprecated

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

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
shouldEnableTruncation,
2121
} from '../ai/utils';
2222
import { stringify } from '../../utils/string';
23+
import type { SpanAttributeValue } from '../../types/span';
2324
import { createLangChainCallbackHandler } from '../langchain';
2425
import type { BaseChatModel, LangChainMessage } from '../langchain/types';
2526
import { normalizeLangChainMessages } from '../langchain/utils';
@@ -39,6 +40,34 @@ let _insideCreateReactAgent = false;
3940

4041
const SENTRY_PATCHED = '__sentry_patched__';
4142

43+
/**
44+
* Builds the span options for a LangGraph `create_agent` span.
45+
*
46+
* @internal Exported so the diagnostics-channel (orchestrion) instrumentation can open the same span
47+
* as the prototype-patching path below without re-declaring the semantic attribute keys.
48+
*/
49+
export function _INTERNAL_getLangGraphCreateAgentSpanOptions(agentName?: string): {
50+
op: string;
51+
name: string;
52+
attributes: Record<string, SpanAttributeValue>;
53+
} {
54+
const attributes: Record<string, SpanAttributeValue> = {
55+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN,
56+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent',
57+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent',
58+
};
59+
60+
if (agentName) {
61+
attributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName;
62+
}
63+
64+
return {
65+
op: 'gen_ai.create_agent',
66+
name: agentName ? `create_agent ${agentName}` : 'create_agent',
67+
attributes,
68+
};
69+
}
70+
4271
/**
4372
* Instruments StateGraph's compile method to create spans for agent creation and invocation
4473
*
@@ -64,53 +93,42 @@ export function instrumentStateGraphCompile(
6493
return Reflect.apply(target, thisArg, args);
6594
}
6695

67-
return startSpan(
68-
{
69-
op: 'gen_ai.create_agent',
70-
name: 'create_agent',
71-
attributes: {
72-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN,
73-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent',
74-
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent',
75-
},
76-
},
77-
span => {
78-
try {
79-
const compiledGraph = Reflect.apply(target, thisArg, args);
80-
const compileOptions = args.length > 0 ? (args[0] as Record<string, unknown>) : {};
81-
82-
// Extract graph name
83-
if (compileOptions?.name && typeof compileOptions.name === 'string') {
84-
span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name);
85-
span.updateName(`create_agent ${compileOptions.name}`);
86-
}
96+
return startSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(), span => {
97+
try {
98+
const compiledGraph = Reflect.apply(target, thisArg, args);
99+
const compileOptions = args.length > 0 ? (args[0] as Record<string, unknown>) : {};
87100

88-
// Instrument agent invoke method on the compiled graph
89-
const originalInvoke = compiledGraph.invoke;
90-
if (originalInvoke && typeof originalInvoke === 'function') {
91-
compiledGraph.invoke = instrumentCompiledGraphInvoke(
92-
originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise<unknown>,
93-
compiledGraph,
94-
compileOptions,
95-
options,
96-
undefined,
97-
sentryHandler,
98-
) as typeof originalInvoke;
99-
}
101+
// Extract graph name
102+
if (compileOptions?.name && typeof compileOptions.name === 'string') {
103+
span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name);
104+
span.updateName(`create_agent ${compileOptions.name}`);
105+
}
100106

101-
return compiledGraph;
102-
} catch (error) {
103-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
104-
captureException(error, {
105-
mechanism: {
106-
handled: false,
107-
type: 'auto.ai.langgraph.error',
108-
},
109-
});
110-
throw error;
107+
// Instrument agent invoke method on the compiled graph
108+
const originalInvoke = compiledGraph.invoke;
109+
if (originalInvoke && typeof originalInvoke === 'function') {
110+
compiledGraph.invoke = instrumentCompiledGraphInvoke(
111+
originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise<unknown>,
112+
compiledGraph,
113+
compileOptions,
114+
options,
115+
undefined,
116+
sentryHandler,
117+
) as typeof originalInvoke;
111118
}
112-
},
113-
);
119+
120+
return compiledGraph;
121+
} catch (error) {
122+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
123+
captureException(error, {
124+
mechanism: {
125+
handled: false,
126+
type: 'auto.ai.langgraph.error',
127+
},
128+
});
129+
throw error;
130+
}
131+
});
114132
},
115133
}) as (...args: unknown[]) => CompiledGraph;
116134

@@ -123,7 +141,7 @@ export function instrumentStateGraphCompile(
123141
*
124142
* Creates a `gen_ai.invoke_agent` span when invoke() is called
125143
*/
126-
function instrumentCompiledGraphInvoke(
144+
export function instrumentCompiledGraphInvoke(
127145
originalInvoke: (...args: unknown[]) => Promise<unknown>,
128146
graphInstance: CompiledGraph,
129147
compileOptions: Record<string, unknown>,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { CompiledGraph, IntegrationFn, LangGraphOptions } from '@sentry/core';
3+
import {
4+
_INTERNAL_getLangGraphCreateAgentSpanOptions,
5+
createLangChainCallbackHandler,
6+
debug,
7+
defineIntegration,
8+
extractAgentNameFromParams,
9+
extractLLMFromParams,
10+
instrumentCompiledGraphInvoke,
11+
LANGGRAPH_INTEGRATION_NAME,
12+
resolveAIRecordingOptions,
13+
startInactiveSpan,
14+
waitForTracingChannelBinding,
15+
wrapToolsWithSpans,
16+
} from '@sentry/core';
17+
import { DEBUG_BUILD } from '../../debug-build';
18+
import { CHANNELS } from '../../orchestrion/channels';
19+
import { bindTracingChannelToSpan } from '../../tracing-channel';
20+
21+
// Same name as the OTel integration by design: when enabled, the OTel 'LangGraph' integration is
22+
// dropped from the default set (see the Node opt-in loader).
23+
const INTEGRATION_NAME = LANGGRAPH_INTEGRATION_NAME;
24+
25+
interface CompileChannelContext {
26+
arguments: unknown[];
27+
result?: unknown;
28+
}
29+
30+
interface CreateReactAgentChannelContext {
31+
arguments: unknown[];
32+
result?: unknown;
33+
}
34+
35+
let subscribed = false;
36+
37+
// `createReactAgent` compiles a `StateGraph` internally; suppress the `create_agent` span for that
38+
// nested compile so a react agent gets a single `invoke_agent` span, matching the OTel path.
39+
let insideCreateReactAgent = false;
40+
41+
const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => {
42+
return {
43+
name: INTEGRATION_NAME,
44+
setupOnce() {
45+
// `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe.
46+
if (!diagnosticsChannel.tracingChannel || subscribed) {
47+
return;
48+
}
49+
subscribed = true;
50+
51+
const resolvedOptions = resolveAIRecordingOptions(options);
52+
const sentryHandler = createLangChainCallbackHandler(resolvedOptions);
53+
54+
// `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers
55+
// after `setupOnce` runs, so wait for it before subscribing.
56+
waitForTracingChannelBinding(() => {
57+
// StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`.
58+
DEBUG_BUILD &&
59+
debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE}"`);
60+
bindTracingChannelToSpan(
61+
diagnosticsChannel.tracingChannel<CompileChannelContext>(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE),
62+
data => {
63+
if (insideCreateReactAgent) {
64+
return undefined;
65+
}
66+
const compileOptions = getFirstArgObject(data.arguments);
67+
const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined;
68+
69+
return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name));
70+
},
71+
{
72+
beforeSpanEnd: (_span, data) => {
73+
wrapCompiledGraphInvoke(
74+
data.result,
75+
getFirstArgObject(data.arguments) ?? {},
76+
resolvedOptions,
77+
null,
78+
sentryHandler,
79+
);
80+
},
81+
},
82+
);
83+
84+
// createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned
85+
// graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`.
86+
DEBUG_BUILD &&
87+
debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_CREATE_REACT_AGENT}"`);
88+
const reactAgentChannel = diagnosticsChannel.tracingChannel<CreateReactAgentChannelContext>(
89+
CHANNELS.LANGGRAPH_CREATE_REACT_AGENT,
90+
);
91+
reactAgentChannel.start.subscribe(message => {
92+
// `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag
93+
// must be on for the duration and off by `end`. It's set here (never in a branch that can
94+
// throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor
95+
// stay off during this call's nested compile. Tool wrapping is guarded for the same reason.
96+
insideCreateReactAgent = true;
97+
try {
98+
const { arguments: args } = message as CreateReactAgentChannelContext;
99+
const params = getFirstArgObject(args);
100+
if (params && Array.isArray(params.tools) && params.tools.length > 0) {
101+
wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined);
102+
}
103+
} catch (error) {
104+
DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error);
105+
}
106+
});
107+
reactAgentChannel.end.subscribe(message => {
108+
insideCreateReactAgent = false;
109+
const { arguments: args, result } = message as CreateReactAgentChannelContext;
110+
const agentName = extractAgentNameFromParams(args) ?? undefined;
111+
const compileOptions = agentName ? { name: agentName } : {};
112+
wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler);
113+
});
114+
// Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on.
115+
reactAgentChannel.error.subscribe(() => {
116+
insideCreateReactAgent = false;
117+
});
118+
});
119+
},
120+
};
121+
}) satisfies IntegrationFn;
122+
123+
function getFirstArgObject(args: unknown[] | undefined): Record<string, unknown> | undefined {
124+
const first = (args ?? [])[0];
125+
126+
return typeof first === 'object' && first !== null ? (first as Record<string, unknown>) : undefined;
127+
}
128+
129+
/**
130+
* Wrap the compiled graph's `invoke` with the shared `invoke_agent` instrumentation, exactly as the
131+
* OTel path does on the returned graph.
132+
*/
133+
function wrapCompiledGraphInvoke(
134+
graph: unknown,
135+
compileOptions: Record<string, unknown>,
136+
options: LangGraphOptions,
137+
llm: ReturnType<typeof extractLLMFromParams>,
138+
sentryHandler: unknown,
139+
): void {
140+
if (!graph || typeof graph !== 'object') {
141+
return;
142+
}
143+
144+
const compiledGraph = graph as CompiledGraph;
145+
const originalInvoke = compiledGraph.invoke;
146+
if (typeof originalInvoke === 'function') {
147+
compiledGraph.invoke = instrumentCompiledGraphInvoke(
148+
originalInvoke.bind(compiledGraph),
149+
compiledGraph,
150+
compileOptions,
151+
options,
152+
llm,
153+
sentryHandler,
154+
);
155+
}
156+
}
157+
158+
/**
159+
* EXPERIMENTAL — orchestrion-driven LangGraph integration. Subscribes to the diagnostics_channels
160+
* injected into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`, so it requires
161+
* the orchestrion runtime hook or bundler plugin.
162+
*/
163+
export const langGraphChannelIntegration = defineIntegration(_langGraphChannelIntegration);
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
11
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
22

3-
// TODO: Stub for the `langgraph` orchestrion integration (ports `SentryLangGraphInstrumentation`).
4-
export const langgraphConfig: InstrumentationConfig[] = [];
3+
// `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the
4+
// matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile`
5+
// and `createReactAgent` both return synchronously; the subscriber wraps the returned compiled graph's
6+
// `invoke` (mirroring the vendored OTel instrumentation, which patched these on the module exports).
7+
const module = (filePath: string): InstrumentationConfig['module'] => ({
8+
name: '@langchain/langgraph',
9+
versionRange: '>=0.0.0 <2.0.0',
10+
filePath,
11+
});
512

6-
export const langgraphChannels = {} as const;
13+
const compileConfig = ['dist/graph/state.cjs', 'dist/graph/state.js'].map(filePath => ({
14+
channelName: 'stateGraphCompile',
15+
module: module(filePath),
16+
functionQuery: { className: 'StateGraph', methodName: 'compile', kind: 'Sync' as const },
17+
}));
18+
19+
// `createReactAgent` is a single function declaration re-exported from both `@langchain/langgraph` and
20+
// `@langchain/langgraph/prebuilt`; hooking its definition file covers every import path.
21+
const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/prebuilt/react_agent_executor.js'].map(
22+
filePath => ({
23+
channelName: 'createReactAgent',
24+
module: module(filePath),
25+
functionQuery: { functionName: 'createReactAgent', kind: 'Sync' as const },
26+
}),
27+
);
28+
29+
export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[];
30+
31+
export const langgraphChannels = {
32+
LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile',
33+
LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent',
34+
} as const;

packages/server-utils/src/orchestrion/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { koaChannelIntegration } from '../integrations/tracing-channel/koa';
1212
import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis';
1313
import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs';
1414
import { knexChannelIntegration } from '../integrations/tracing-channel/knex';
15+
import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph';
1516
import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';
1617
import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
1718
import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2';
@@ -37,6 +38,7 @@ export {
3738
ioredisChannelIntegration,
3839
kafkajsChannelIntegration,
3940
knexChannelIntegration,
41+
langGraphChannelIntegration,
4042
lruMemoizerChannelIntegration,
4143
mysqlChannelIntegration,
4244
mysql2ChannelIntegration,
@@ -88,6 +90,7 @@ export const channelIntegrations = {
8890
openaiIntegration: openaiChannelIntegration,
8991
anthropicIntegration: anthropicChannelIntegration,
9092
googleGenAIIntegration: googleGenAIChannelIntegration,
93+
langGraphIntegration: langGraphChannelIntegration,
9194
vercelAiIntegration: vercelAiChannelIntegration,
9295
amqplibIntegration: amqplibChannelIntegration,
9396
hapiIntegration: hapiChannelIntegration,

0 commit comments

Comments
 (0)