diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 647886d41199..271cc7bbb004 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -219,7 +219,14 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from '. 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'; +export { + instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentLangGraph, + instrumentCompiledGraphInvoke, + _INTERNAL_getLangGraphCreateAgentSpanOptions, +} from './tracing/langgraph'; +export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d4db0de3a6f3..daf2f55552ea 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -20,6 +20,7 @@ import { shouldEnableTruncation, } from '../ai/utils'; import { stringify } from '../../utils/string'; +import type { SpanAttributeValue } from '../../types/span'; import { createLangChainCallbackHandler } from '../langchain'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -39,6 +40,34 @@ let _insideCreateReactAgent = false; const SENTRY_PATCHED = '__sentry_patched__'; +/** + * Builds the span options for a LangGraph `create_agent` span. + * + * @internal Exported so the diagnostics-channel (orchestrion) instrumentation can open the same span + * as the prototype-patching path below without re-declaring the semantic attribute keys. + */ +export function _INTERNAL_getLangGraphCreateAgentSpanOptions(agentName?: string): { + op: string; + name: string; + attributes: Record; +} { + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', + }; + + if (agentName) { + attributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName; + } + + return { + op: 'gen_ai.create_agent', + name: agentName ? `create_agent ${agentName}` : 'create_agent', + attributes, + }; +} + /** * Instruments StateGraph's compile method to create spans for agent creation and invocation * @@ -64,53 +93,42 @@ export function instrumentStateGraphCompile( return Reflect.apply(target, thisArg, args); } - return startSpan( - { - op: 'gen_ai.create_agent', - name: 'create_agent', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', - }, - }, - span => { - try { - const compiledGraph = Reflect.apply(target, thisArg, args); - const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - - // Extract graph name - if (compileOptions?.name && typeof compileOptions.name === 'string') { - span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); - span.updateName(`create_agent ${compileOptions.name}`); - } + return startSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(), span => { + try { + const compiledGraph = Reflect.apply(target, thisArg, args); + const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - // Instrument agent invoke method on the compiled graph - const originalInvoke = compiledGraph.invoke; - if (originalInvoke && typeof originalInvoke === 'function') { - compiledGraph.invoke = instrumentCompiledGraphInvoke( - originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, - compiledGraph, - compileOptions, - options, - undefined, - sentryHandler, - ) as typeof originalInvoke; - } + // Extract graph name + if (compileOptions?.name && typeof compileOptions.name === 'string') { + span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); + span.updateName(`create_agent ${compileOptions.name}`); + } - return compiledGraph; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); - throw error; + // Instrument agent invoke method on the compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + options, + undefined, + sentryHandler, + ) as typeof originalInvoke; } - }, - ); + + return compiledGraph; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }); }, }) as (...args: unknown[]) => CompiledGraph; @@ -123,7 +141,7 @@ export function instrumentStateGraphCompile( * * Creates a `gen_ai.invoke_agent` span when invoke() is called */ -function instrumentCompiledGraphInvoke( +export function instrumentCompiledGraphInvoke( originalInvoke: (...args: unknown[]) => Promise, graphInstance: CompiledGraph, compileOptions: Record, diff --git a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts new file mode 100644 index 000000000000..c5e601c61d9d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts @@ -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; + +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(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( + 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; + 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 | undefined { + const first = (args ?? [])[0]; + + return typeof first === 'object' && first !== null ? (first as Record) : 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, + options: LangGraphOptions, + llm: ReturnType, + 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); diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index ce93f1e993d5..8eab70919b70 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -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; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index e36c9360dc51..2282477c32bb 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -12,6 +12,7 @@ import { koaChannelIntegration } from '../integrations/tracing-channel/koa'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; +import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; @@ -37,6 +38,7 @@ export { ioredisChannelIntegration, kafkajsChannelIntegration, knexChannelIntegration, + langGraphChannelIntegration, lruMemoizerChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, @@ -88,6 +90,7 @@ export const channelIntegrations = { openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration, googleGenAIIntegration: googleGenAIChannelIntegration, + langGraphIntegration: langGraphChannelIntegration, vercelAiIntegration: vercelAiChannelIntegration, amqplibIntegration: amqplibChannelIntegration, hapiIntegration: hapiChannelIntegration,