Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 63 additions & 45 deletions packages/core/src/tracing/langgraph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, SpanAttributeValue>;
} {
const attributes: Record<string, SpanAttributeValue> = {
[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
*
Expand All @@ -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<string, unknown>) : {};

// 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<string, unknown>) : {};

// 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<unknown>,
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<unknown>,
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;

Expand All @@ -123,7 +141,7 @@ export function instrumentStateGraphCompile(
*
* Creates a `gen_ai.invoke_agent` span when invoke() is called
*/
function instrumentCompiledGraphInvoke(
Comment thread
cursor[bot] marked this conversation as resolved.
export function instrumentCompiledGraphInvoke(
originalInvoke: (...args: unknown[]) => Promise<unknown>,
graphInstance: CompiledGraph,
compileOptions: Record<string, unknown>,
Expand Down
163 changes: 163 additions & 0 deletions packages/server-utils/src/integrations/tracing-channel/langgraph.ts
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The module-level insideCreateReactAgent flag is not concurrency-safe, leading to race conditions where one request's state can cause incorrect span suppression in another unrelated request.
Severity: HIGH

Suggested Fix

Replace the module-level insideCreateReactAgent flag with a request-scoped state management solution, such as AsyncLocalStorage. This will ensure that the flag's state is isolated to the specific asynchronous context of each request, preventing interference between concurrent operations.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/server-utils/src/integrations/tracing-channel/langgraph.ts#L39

Potential issue: The `insideCreateReactAgent` flag, declared as a module-level
singleton, is modified by event subscribers for `reactAgentChannel`. In a concurrent
environment like a Node.js server, this creates a race condition. If one request calls
`createReactAgent` and sets the flag, a simultaneous, unrelated request to
`StateGraph.compile` will read the globally set flag and incorrectly suppress its
`create_agent` span. This happens because the event callbacks operate in a shared module
context, unlike the OTel path which uses a synchronous `try/finally` block to scope the
flag's state.

Also affects:

  • packages/server-utils/src/integrations/tracing-channel/langgraph.ts:63~63
  • packages/server-utils/src/integrations/tracing-channel/langgraph.ts:99~99
  • packages/server-utils/src/integrations/tracing-channel/langgraph.ts:102~102
  • packages/server-utils/src/integrations/tracing-channel/langgraph.ts:110~110

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: afaik create_agent spans are no longer relevant for us, so that this is still emitted in the langgraph instrumentation seems like a leftover. so we could think about simplifying all of this? or if we want to keep parity for now that's also fine I think we should just add a task for the major to get rid of this I think

@logaretm logaretm Jul 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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;
Comment thread
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing feat integration tests

Medium Severity

This is a feat PR that adds a new orchestrion diagnostics-channel LangGraph integration, but the diff includes no integration or E2E test covering the new channel path (StateGraph.compile / createReactAgent subscribe + invoke wrapping). Existing OTel suite coverage does not validate this rewrite. Flagged because the review rules require at least one integration or E2E test on feat PRs.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 5b8c0af. Configure here.

34 changes: 31 additions & 3 deletions packages/server-utils/src/orchestrion/config/langgraph.ts
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;
3 changes: 3 additions & 0 deletions packages/server-utils/src/orchestrion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +38,7 @@ export {
ioredisChannelIntegration,
kafkajsChannelIntegration,
knexChannelIntegration,
langGraphChannelIntegration,
lruMemoizerChannelIntegration,
mysqlChannelIntegration,
mysql2ChannelIntegration,
Expand Down Expand Up @@ -88,6 +90,7 @@ export const channelIntegrations = {
openaiIntegration: openaiChannelIntegration,
anthropicIntegration: anthropicChannelIntegration,
googleGenAIIntegration: googleGenAIChannelIntegration,
langGraphIntegration: langGraphChannelIntegration,
vercelAiIntegration: vercelAiChannelIntegration,
amqplibIntegration: amqplibChannelIntegration,
hapiIntegration: hapiChannelIntegration,
Expand Down
Loading