-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(server-utils): Rewrite SentryLangGraphInstrumentation to orchestrion
#22268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import * as diagnosticsChannel from 'node:diagnostics_channel'; | ||
| import type { CompiledGraph, IntegrationFn, LangGraphOptions } from '@sentry/core'; | ||
| import { | ||
| _INTERNAL_getLangGraphCreateAgentSpanOptions, | ||
| createLangChainCallbackHandler, | ||
| debug, | ||
| defineIntegration, | ||
| extractAgentNameFromParams, | ||
| extractLLMFromParams, | ||
| instrumentCompiledGraphInvoke, | ||
| LANGGRAPH_INTEGRATION_NAME, | ||
| resolveAIRecordingOptions, | ||
| startInactiveSpan, | ||
| waitForTracingChannelBinding, | ||
| wrapToolsWithSpans, | ||
| } from '@sentry/core'; | ||
| import { DEBUG_BUILD } from '../../debug-build'; | ||
| import { CHANNELS } from '../../orchestrion/channels'; | ||
| import { bindTracingChannelToSpan } from '../../tracing-channel'; | ||
|
|
||
| // Same name as the OTel integration by design: when enabled, the OTel 'LangGraph' integration is | ||
| // dropped from the default set (see the Node opt-in loader). | ||
| const INTEGRATION_NAME = LANGGRAPH_INTEGRATION_NAME; | ||
|
|
||
| interface CompileChannelContext { | ||
| arguments: unknown[]; | ||
| result?: unknown; | ||
| } | ||
|
|
||
| interface CreateReactAgentChannelContext { | ||
| arguments: unknown[]; | ||
| result?: unknown; | ||
| } | ||
|
|
||
| let subscribed = false; | ||
|
|
||
| // `createReactAgent` compiles a `StateGraph` internally; suppress the `create_agent` span for that | ||
| // nested compile so a react agent gets a single `invoke_agent` span, matching the OTel path. | ||
| let insideCreateReactAgent = false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The module-level Suggested FixReplace the module-level Prompt for AI AgentAlso affects:
Did we get this right? 👍 / 👎 to inform future reviews.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. m: afaik
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So is it safe to drop entirely? If it already gets dropped somewhere along the line then we should simplify sure. Otherwise, I can create an issue and park a PR for v11 for it.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The OTel path still emits it but we will drop it eventually just didn't have the time yet, but I think it's fine to park this for v11 then we can align this more broadly. |
||
|
|
||
| const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => { | ||
| return { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. | ||
| if (!diagnosticsChannel.tracingChannel || subscribed) { | ||
| return; | ||
| } | ||
| subscribed = true; | ||
|
|
||
| const resolvedOptions = resolveAIRecordingOptions(options); | ||
| const sentryHandler = createLangChainCallbackHandler(resolvedOptions); | ||
|
|
||
| // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers | ||
| // after `setupOnce` runs, so wait for it before subscribing. | ||
| waitForTracingChannelBinding(() => { | ||
| // StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`. | ||
| DEBUG_BUILD && | ||
| debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE}"`); | ||
| bindTracingChannelToSpan( | ||
| diagnosticsChannel.tracingChannel<CompileChannelContext>(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE), | ||
| data => { | ||
| if (insideCreateReactAgent) { | ||
| return undefined; | ||
| } | ||
| const compileOptions = getFirstArgObject(data.arguments); | ||
| const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined; | ||
|
|
||
| return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); | ||
| }, | ||
| { | ||
| beforeSpanEnd: (_span, data) => { | ||
| wrapCompiledGraphInvoke( | ||
| data.result, | ||
| getFirstArgObject(data.arguments) ?? {}, | ||
| resolvedOptions, | ||
| null, | ||
| sentryHandler, | ||
| ); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| // createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned | ||
| // graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`. | ||
| DEBUG_BUILD && | ||
| debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_CREATE_REACT_AGENT}"`); | ||
| const reactAgentChannel = diagnosticsChannel.tracingChannel<CreateReactAgentChannelContext>( | ||
| CHANNELS.LANGGRAPH_CREATE_REACT_AGENT, | ||
| ); | ||
| reactAgentChannel.start.subscribe(message => { | ||
| // `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag | ||
| // must be on for the duration and off by `end`. It's set here (never in a branch that can | ||
| // throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor | ||
| // stay off during this call's nested compile. Tool wrapping is guarded for the same reason. | ||
| insideCreateReactAgent = true; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| try { | ||
| const { arguments: args } = message as CreateReactAgentChannelContext; | ||
| const params = getFirstArgObject(args); | ||
| if (params && Array.isArray(params.tools) && params.tools.length > 0) { | ||
| wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined); | ||
| } | ||
| } catch (error) { | ||
| DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error); | ||
| } | ||
| }); | ||
| reactAgentChannel.end.subscribe(message => { | ||
| insideCreateReactAgent = false; | ||
| const { arguments: args, result } = message as CreateReactAgentChannelContext; | ||
| const agentName = extractAgentNameFromParams(args) ?? undefined; | ||
| const compileOptions = agentName ? { name: agentName } : {}; | ||
| wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler); | ||
| }); | ||
| // Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on. | ||
| reactAgentChannel.error.subscribe(() => { | ||
| insideCreateReactAgent = false; | ||
| }); | ||
| }); | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| function getFirstArgObject(args: unknown[] | undefined): Record<string, unknown> | undefined { | ||
| const first = (args ?? [])[0]; | ||
|
|
||
| return typeof first === 'object' && first !== null ? (first as Record<string, unknown>) : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Wrap the compiled graph's `invoke` with the shared `invoke_agent` instrumentation, exactly as the | ||
| * OTel path does on the returned graph. | ||
| */ | ||
| function wrapCompiledGraphInvoke( | ||
| graph: unknown, | ||
| compileOptions: Record<string, unknown>, | ||
| options: LangGraphOptions, | ||
| llm: ReturnType<typeof extractLLMFromParams>, | ||
| sentryHandler: unknown, | ||
| ): void { | ||
| if (!graph || typeof graph !== 'object') { | ||
| return; | ||
| } | ||
|
|
||
| const compiledGraph = graph as CompiledGraph; | ||
| const originalInvoke = compiledGraph.invoke; | ||
| if (typeof originalInvoke === 'function') { | ||
| compiledGraph.invoke = instrumentCompiledGraphInvoke( | ||
| originalInvoke.bind(compiledGraph), | ||
| compiledGraph, | ||
| compileOptions, | ||
| options, | ||
| llm, | ||
| sentryHandler, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * EXPERIMENTAL — orchestrion-driven LangGraph integration. Subscribes to the diagnostics_channels | ||
| * injected into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`, so it requires | ||
| * the orchestrion runtime hook or bundler plugin. | ||
| */ | ||
| export const langGraphChannelIntegration = defineIntegration(_langGraphChannelIntegration); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing feat integration testsMedium Severity This is a Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit 5b8c0af. Configure here. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,34 @@ | ||
| import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; | ||
|
|
||
| // TODO: Stub for the `langgraph` orchestrion integration (ports `SentryLangGraphInstrumentation`). | ||
| export const langgraphConfig: InstrumentationConfig[] = []; | ||
| // `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the | ||
| // matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` | ||
| // and `createReactAgent` both return synchronously; the subscriber wraps the returned compiled graph's | ||
| // `invoke` (mirroring the vendored OTel instrumentation, which patched these on the module exports). | ||
| const module = (filePath: string): InstrumentationConfig['module'] => ({ | ||
| name: '@langchain/langgraph', | ||
| versionRange: '>=0.0.0 <2.0.0', | ||
| filePath, | ||
| }); | ||
|
|
||
| export const langgraphChannels = {} as const; | ||
| const compileConfig = ['dist/graph/state.cjs', 'dist/graph/state.js'].map(filePath => ({ | ||
| channelName: 'stateGraphCompile', | ||
| module: module(filePath), | ||
| functionQuery: { className: 'StateGraph', methodName: 'compile', kind: 'Sync' as const }, | ||
| })); | ||
|
|
||
| // `createReactAgent` is a single function declaration re-exported from both `@langchain/langgraph` and | ||
| // `@langchain/langgraph/prebuilt`; hooking its definition file covers every import path. | ||
| const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/prebuilt/react_agent_executor.js'].map( | ||
| filePath => ({ | ||
| channelName: 'createReactAgent', | ||
| module: module(filePath), | ||
| functionQuery: { functionName: 'createReactAgent', kind: 'Sync' as const }, | ||
| }), | ||
| ); | ||
|
|
||
| export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[]; | ||
|
|
||
| export const langgraphChannels = { | ||
| LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', | ||
| LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent', | ||
| } as const; |


Uh oh!
There was an error while loading. Please reload this page.