Skip to content

Commit 5b8c0af

Browse files
committed
feat(server-utils): Rewrite LangGraph integration to orchestrion
Rewrites the LangGraph integration to a diagnostics-channel listener instead of `InstrumentationBase`, with orchestrion injecting the channels into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`. The subscriber creates the `create_agent` span around `compile`, wraps the returned compiled graph's `invoke` with the shared `invoke_agent` instrumentation, and wraps react-agent tools, reusing the existing core span builders so span output is identical. The OTel path stays as the fallback when orchestrion isn't injected.
1 parent c85347c commit 5b8c0af

5 files changed

Lines changed: 227 additions & 5 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: 30 additions & 1 deletion
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
*
@@ -123,7 +152,7 @@ export function instrumentStateGraphCompile(
123152
*
124153
* Creates a `gen_ai.invoke_agent` span when invoke() is called
125154
*/
126-
function instrumentCompiledGraphInvoke(
155+
export function instrumentCompiledGraphInvoke(
127156
originalInvoke: (...args: unknown[]) => Promise<unknown>,
128157
graphInstance: CompiledGraph,
129158
compileOptions: Record<string, unknown>,
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
insideCreateReactAgent = true;
93+
const { arguments: args } = message as CreateReactAgentChannelContext;
94+
const params = getFirstArgObject(args);
95+
if (params && Array.isArray(params.tools) && params.tools.length > 0) {
96+
wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined);
97+
}
98+
});
99+
reactAgentChannel.end.subscribe(message => {
100+
insideCreateReactAgent = false;
101+
const { arguments: args, result } = message as CreateReactAgentChannelContext;
102+
const agentName = extractAgentNameFromParams(args) ?? undefined;
103+
const compileOptions = agentName ? { name: agentName } : {};
104+
wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler);
105+
});
106+
// Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on.
107+
reactAgentChannel.error.subscribe(() => {
108+
insideCreateReactAgent = false;
109+
});
110+
});
111+
},
112+
};
113+
}) satisfies IntegrationFn;
114+
115+
function getFirstArgObject(args: unknown[] | undefined): Record<string, unknown> | undefined {
116+
const first = (args ?? [])[0];
117+
118+
return typeof first === 'object' && first !== null ? (first as Record<string, unknown>) : undefined;
119+
}
120+
121+
/**
122+
* Wrap the compiled graph's `invoke` with the shared `invoke_agent` instrumentation, exactly as the
123+
* OTel path does on the returned graph.
124+
*/
125+
function wrapCompiledGraphInvoke(
126+
graph: unknown,
127+
compileOptions: Record<string, unknown>,
128+
options: LangGraphOptions,
129+
llm: ReturnType<typeof extractLLMFromParams>,
130+
sentryHandler: unknown,
131+
): void {
132+
if (!graph || typeof graph !== 'object') {
133+
return;
134+
}
135+
136+
const compiledGraph = graph as CompiledGraph;
137+
const originalInvoke = compiledGraph.invoke;
138+
if (typeof originalInvoke === 'function') {
139+
compiledGraph.invoke = instrumentCompiledGraphInvoke(
140+
originalInvoke.bind(compiledGraph),
141+
compiledGraph,
142+
compileOptions,
143+
options,
144+
llm,
145+
sentryHandler,
146+
);
147+
}
148+
}
149+
150+
/**
151+
* EXPERIMENTAL — orchestrion-driven LangGraph integration. Subscribes to the diagnostics_channels
152+
* injected into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`, so it requires
153+
* the orchestrion runtime hook or bundler plugin.
154+
*/
155+
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
@@ -10,6 +10,7 @@ import {
1010
import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi';
1111
import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis';
1212
import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs';
13+
import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph';
1314
import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';
1415
import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
1516
import { openaiChannelIntegration } from '../integrations/tracing-channel/openai';
@@ -32,6 +33,7 @@ export {
3233
hapiChannelIntegration,
3334
ioredisChannelIntegration,
3435
kafkajsChannelIntegration,
36+
langGraphChannelIntegration,
3537
lruMemoizerChannelIntegration,
3638
mysqlChannelIntegration,
3739
openaiChannelIntegration,
@@ -80,6 +82,7 @@ export const channelIntegrations = {
8082
openaiIntegration: openaiChannelIntegration,
8183
anthropicIntegration: anthropicChannelIntegration,
8284
googleGenAIIntegration: googleGenAIChannelIntegration,
85+
langGraphIntegration: langGraphChannelIntegration,
8386
vercelAiIntegration: vercelAiChannelIntegration,
8487
amqplibIntegration: amqplibChannelIntegration,
8588
hapiIntegration: hapiChannelIntegration,

0 commit comments

Comments
 (0)