From d11a5439f05f2a6fb2b78744cbd9f3195abb667c Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 14 Oct 2026 14:41:04 +0200 Subject: [PATCH 1/3] fix(server-utils): Nest LangChain run spans under their parent run The callback handler ignored parentRunId, so nested runs became siblings under the caller's span. Chain and tool spans also lost the conversation id on @langchain/core >= 1.1.40, which keeps configurable out of run metadata. Start each run under its parent run's span, and share the conversation id across the open spans of the run tree. Fixes #25327 Co-Authored-By: Claude Opus 5.5 --- .../suites/tracing/langchain/v1/test.ts | 32 ++- .../server-utils/src/ai/langchain/index.ts | 99 +++++---- .../tracing/langchain-invoke-agent.test.ts | 205 ++++++++++++++++-- 3 files changed, 264 insertions(+), 72 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index 4260da49bdc4..6abf7a4dcc84 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -260,26 +260,34 @@ describe('LangChain integration (v1)', () => { expect(chatSpans).toHaveLength(2); expect( - chatSpans.map(span => [span.attributes[GEN_AI_CONVERSATION_ID]?.value, ancestorNames(span)[0]]).sort(), + chatSpans + .map(span => [ + span.attributes[GEN_AI_CONVERSATION_ID]?.value, + ancestorNames(span).find(name => name === 'auto' || name === 'handler'), + ]) + .sort(), ).toEqual([ ['thread_auto', 'auto'], ['thread_handler', 'handler'], ]); - const handlerRunSpans = spans.filter( + // Each run span sits under its parent run's span. The id only reaches runs still open when the model + // call reveals it, so the step that ran before the model call has none. + const handlerSpans = spans.filter( span => - span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' && - span.attributes[SENTRY_OP]?.value !== GEN_AI_CHAT, + span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' && ancestorNames(span).includes('handler'), ); - expect(handlerRunSpans.map(span => span.name).sort()).toEqual([ - 'execute_tool lookup_weather', - 'invoke_agent RunnableLambda', - 'invoke_agent nested_chain', - 'invoke_agent outer', + expect( + handlerSpans + .map(span => [span.name, ancestorNames(span)[0], span.attributes[GEN_AI_CONVERSATION_ID]?.value]) + .sort(), + ).toEqual([ + ['chat claude-3-5-sonnet-20241022', 'invoke_agent nested_chain', 'thread_handler'], + ['execute_tool lookup_weather', 'invoke_agent outer', 'thread_handler'], + ['invoke_agent RunnableLambda', 'invoke_agent nested_chain', undefined], + ['invoke_agent nested_chain', 'execute_tool lookup_weather', 'thread_handler'], + ['invoke_agent outer', 'handler', 'thread_handler'], ]); - for (const span of handlerRunSpans) { - expect(ancestorNames(span)).toContain('handler'); - } }); }, { diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index 11a1afb7d391..f166cdaa09ff 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -1,7 +1,16 @@ /* eslint-disable max-lines */ -import { getClient, hasSpanStreamingEnabled, SPAN_STATUS_ERROR, startSpanManual, stringify } from '@sentry/core'; +import { + getClient, + hasSpanStreamingEnabled, + SPAN_STATUS_ERROR, + spanToJSON, + startInactiveSpan, + stringify, + withActiveSpan, +} from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { + GEN_AI_CONVERSATION_ID, GEN_AI_OPERATION_NAME, GEN_AI_PIPELINE_NAME, GEN_AI_REQUEST_MODEL, @@ -49,6 +58,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): // Internal state - single instance tracks all spans const spanMap = new Map(); const decideRunIds = new Set(); + const runs = new Map(); /** * Exit a span and clean up @@ -60,6 +70,35 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): } spanMap.delete(runId); decideRunIds.delete(runId); + runs.delete(runId); + }; + + // LangChain never makes our run spans active, so each run starts under its parent run's span. + // With no span for the parent run (skipped or unseen), the active span stays the parent. + const startRunSpan = (runId: string, parentRunId: string | undefined, start: () => Span): void => { + const parentSpan = parentRunId ? spanMap.get(parentRunId) : undefined; + const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); + spanMap.set(runId, span); + + // `@langchain/core` >= 1.1.40 keeps `configurable` out of run metadata, so often only the chat span knows + // the conversation id. A span takes it from its parent, or hands it up to open ancestors without one. + // The own id is read off the span, since one from `Sentry.setConversationId()` is only set there. + const ownId = spanToJSON(span).attributes[GEN_AI_CONVERSATION_ID]; + const parentConversationId = parentRunId ? runs.get(parentRunId)?.conversationId : undefined; + const conversationId = typeof ownId === 'string' ? ownId : parentConversationId; + if (conversationId && conversationId !== ownId) { + span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); + } + runs.set(runId, { parentRunId, conversationId }); + + for (let id = parentRunId; conversationId && id; id = runs.get(id)?.parentRunId) { + const run = runs.get(id); + if (!run || run.conversationId) { + break; + } + run.conversationId = conversationId; + spanMap.get(id)?.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); + } }; /** @@ -91,7 +130,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, prompts: string[], runId: string, - _parentRunId?: string, + parentRunId?: string, _extraParams?: Record, tags?: string[], metadata?: Record, @@ -110,8 +149,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): typeof attributes[GEN_AI_OPERATION_NAME] === 'string' ? attributes[GEN_AI_OPERATION_NAME] : 'unknown'; const client = getClient(); - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. name: (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) @@ -123,11 +162,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SENTRY_OP]: GEN_AI_CHAT, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -136,7 +171,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, messages: unknown, runId: string, - _parentRunId?: string, + parentRunId?: string, extraParams?: Record, tags?: string[], metadata?: Record, @@ -161,8 +196,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): typeof attributes[GEN_AI_OPERATION_NAME] === 'string' ? attributes[GEN_AI_OPERATION_NAME] : 'unknown'; const client = getClient(); - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. name: (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) @@ -174,11 +209,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SENTRY_OP]: GEN_AI_CHAT, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -196,8 +227,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): if (attributes) { span.setAttributes(attributes); } - exitSpan(runId); } + exitSpan(runId); }, // LLM Error Handler - note: handleLLMError with capital LLM @@ -207,8 +238,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const span = spanMap.get(runId); if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - exitSpan(runId); } + exitSpan(runId); }, // Chain Start Handler @@ -216,7 +247,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): chain: { name?: string }, inputs: Record, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, _runType?: string, @@ -224,7 +255,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ) { // A Jev call is a real model call, so it is recorded inside an agent too. if (isTypeSafeClassifier(chain)) { - spanMap.set(runId, startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs)); + startRunSpan(runId, parentRunId, () => startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs)); decideRunIds.add(runId); return; } @@ -253,8 +284,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const client = getClient(); - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ // With span streaming, the name leads with the operation per the agent templates. The // chain name is bounded, so it stays; the `'unknown_chain'` sentinel is dropped instead. name: !(client && hasSpanStreamingEnabled(client)) @@ -266,11 +297,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SENTRY_OP]: GEN_AI_INVOKE_AGENT, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -306,7 +333,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): tool: { name?: string }, input: string, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, runName?: string, @@ -331,18 +358,14 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): attributes[GEN_AI_TOOL_CALL_ARGUMENTS] = input; } - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ name: `execute_tool ${toolName}`, attributes: { ...attributes, [SENTRY_OP]: GEN_AI_EXECUTE_TOOL, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -359,8 +382,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): [GEN_AI_TOOL_CALL_RESULT]: stringify(content, String), }); } - exitSpan(runId); } + exitSpan(runId); }, // Tool Error Handler @@ -370,8 +393,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const span = spanMap.get(runId); if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - exitSpan(runId); } + exitSpan(runId); }, // LangChain BaseCallbackHandler required methods diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts index 592a9aa9f362..0a7ae1888388 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts @@ -1,42 +1,52 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { GEN_AI_AGENT_NAME, + GEN_AI_CONVERSATION_ID, GEN_AI_OPERATION_NAME, GEN_AI_PIPELINE_NAME, SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { GEN_AI_DECIDE } from '@sentry/conventions/op'; -import { getClient, getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import { + conversationIdIntegration, + getClient, + getMainCarrier, + setConversationId, + setCurrentClient, + spanToJSON, + spanToStaticSpanJSON, + startSpan, +} from '@sentry/core'; import type { Span } from '@sentry/core'; import { createLangChainCallbackHandler } from '../../../../src/ai/langchain'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; -describe('LangChain invoke_agent span names', () => { - beforeEach(() => { - getMainCarrier().__SENTRY__ = undefined; - }); +beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; +}); - afterEach(() => { - getMainCarrier().__SENTRY__ = undefined; - }); +afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; +}); - function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1, - traceLifecycle, - }), - ); - setCurrentClient(client); - client.init(); +function setupClient(traceLifecycle: 'static' | 'stream' = 'static'): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); - const endedSpans: Span[] = []; - client.on('spanEnd', span => endedSpans.push(span)); - return endedSpans; - } + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; +} +describe('LangChain invoke_agent span names', () => { function runChain(runName?: string, chain: { name?: string } = {}): void { const handler = createLangChainCallbackHandler(); handler.handleChainStart?.( @@ -128,3 +138,154 @@ describe('LangChain invoke_agent span names', () => { expect(endedSpans.map(span => spanToStaticSpanJSON(span).description)).toEqual(['decide jev-latest']); }); }); + +describe('LangChain callback handler run tree', () => { + function byName(spans: Span[]): Record> { + return Object.fromEntries(spans.map(spanToJSON).map(json => [json.name, json])); + } + + function conversationIds(spans: Span[]): Record { + return Object.fromEntries(spans.map(spanToJSON).map(json => [json.name, json.attributes[GEN_AI_CONVERSATION_ID]])); + } + + const chatModel = { + lc: 1, + type: 'constructor', + id: ['langchain', 'chat_models', 'openai', 'ChatOpenAI'], + kwargs: {}, + }; + + it('nests each run under the span of its parent run', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + startSpan({ name: 'caller' }, () => { + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleToolStart?.({}, '{}', 'tool', 'outer', undefined, undefined, 'lookup_weather'); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'tool'); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleToolEnd?.('sunny', 'tool'); + handler.handleChainEnd?.({}, 'outer'); + }); + + const spans = byName(endedSpans); + expect(spans['chain outer']?.parent_span_id).toBe(spans['caller']?.span_id); + expect(spans['execute_tool lookup_weather']?.parent_span_id).toBe(spans['chain outer']?.span_id); + expect(spans['chat unknown']?.parent_span_id).toBe(spans['execute_tool lookup_weather']?.span_id); + }); + + it('keeps the active span as parent when the parent run has no span', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + // A LangGraph agent inside a traced chain: its own runs are skipped, and its agent span is active. + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + startSpan({ name: 'invoke_agent graph' }, () => { + handler.handleChainStart?.({}, {}, 'graph-step', 'outer', undefined, { __sentry_langgraph__: true }); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'graph-step'); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleChainEnd?.({}, 'graph-step'); + }); + handler.handleChainEnd?.({}, 'outer'); + + const spans = byName(endedSpans); + expect(spans['chat unknown']?.parent_span_id).toBe(spans['invoke_agent graph']?.span_id); + }); + + it('passes the conversation id down to child runs without one', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, { thread_id: 'thread-1' }, undefined, 'outer'); + handler.handleToolStart?.({}, '{}', 'tool', 'outer', undefined, undefined, 'lookup_weather'); + handler.handleToolEnd?.('sunny', 'tool'); + handler.handleChainEnd?.({}, 'outer'); + + expect(conversationIds(endedSpans)).toEqual({ + 'chain outer': 'thread-1', + 'execute_tool lookup_weather': 'thread-1', + }); + }); + + it('backfills the conversation id onto open ancestors without one', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleToolStart?.({}, '{}', 'tool', 'outer', undefined, undefined, 'lookup_weather'); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'tool', undefined, undefined, { thread_id: 'thread-1' }); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleToolEnd?.('sunny', 'tool'); + handler.handleChainEnd?.({}, 'outer'); + + expect(conversationIds(endedSpans)).toEqual({ + 'chat unknown': 'thread-1', + 'execute_tool lookup_weather': 'thread-1', + 'chain outer': 'thread-1', + }); + }); + + it('does not backfill past an ancestor that has its own conversation id', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, { sessionId: 'session-1' }, undefined, 'outer'); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'outer', undefined, undefined, { thread_id: 'thread-1' }); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleChainEnd?.({}, 'outer'); + + expect(conversationIds(endedSpans)).toEqual({ + 'chat unknown': 'thread-1', + 'chain outer': 'session-1', + }); + }); + + it('keeps run trees that interleave on one handler apart', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'a', undefined, undefined, { thread_id: 'thread-a' }, undefined, 'a'); + handler.handleChainStart?.({}, {}, 'b', undefined, undefined, undefined, undefined, 'b'); + handler.handleToolStart?.({}, '{}', 'b-tool', 'b', undefined, undefined, 'tool_b'); + handler.handleToolStart?.({}, '{}', 'a-tool', 'a', undefined, undefined, 'tool_a'); + handler.handleChatModelStart?.(chatModel, [[]], 'b-chat', 'b-tool', undefined, undefined, { + thread_id: 'thread-b', + }); + handler.handleLLMEnd?.({ generations: [] }, 'b-chat'); + for (const runId of ['a-tool', 'b-tool']) { + handler.handleToolEnd?.('sunny', runId); + } + for (const runId of ['a', 'b']) { + handler.handleChainEnd?.({}, runId); + } + + const spans = byName(endedSpans); + expect(spans['execute_tool tool_a']?.parent_span_id).toBe(spans['chain a']?.span_id); + expect(spans['execute_tool tool_b']?.parent_span_id).toBe(spans['chain b']?.span_id); + expect(conversationIds(endedSpans)).toEqual({ + 'chain a': 'thread-a', + 'execute_tool tool_a': 'thread-a', + 'chain b': 'thread-b', + 'execute_tool tool_b': 'thread-b', + 'chat unknown': 'thread-b', + }); + }); + + it('lets an id from Sentry.setConversationId() win over one from a child run', () => { + const endedSpans = setupClient(); + getClient()!.addIntegration(conversationIdIntegration()); + const handler = createLangChainCallbackHandler(); + + setConversationId('from-scope'); + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'outer', undefined, undefined, { thread_id: 'thread-1' }); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleChainEnd?.({}, 'outer'); + setConversationId(null); + + expect(conversationIds(endedSpans)).toEqual({ + 'chain outer': 'from-scope', + 'chat unknown': 'from-scope', + }); + }); +}); From 22d34e9245249934a9ed0177a36138e2ff4eddc4 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 15 Oct 2026 11:31:48 +0200 Subject: [PATCH 2/3] fix(server-utils): Skip ignored and bypassed run spans as LangChain parents A child run was always started under its parent run's span. That span may be dropped by ignoreSpans, which made every descendant unrecorded, and a span opened inside the run body (e.g. Sentry.startSpan around a nested invoke) got bypassed. Walk up to the nearest run span that isn't an ignored child, as core does for ignored spans, so an ignored root still drops its subtree. Keep the active span as the parent when it is no longer the one the ancestor run started under. Co-Authored-By: Claude Opus 5.5 --- .../server-utils/src/ai/langchain/index.ts | 28 ++++++++-- .../tracing/langchain-invoke-agent.test.ts | 55 ++++++++++++++++++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index f166cdaa09ff..df6acf626880 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -1,8 +1,11 @@ /* eslint-disable max-lines */ import { + getActiveSpan, getClient, + getRootSpan, hasSpanStreamingEnabled, SPAN_STATUS_ERROR, + spanIsIgnored, spanToJSON, startInactiveSpan, stringify, @@ -58,7 +61,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): // Internal state - single instance tracks all spans const spanMap = new Map(); const decideRunIds = new Set(); - const runs = new Map(); + const runs = new Map(); /** * Exit a span and clean up @@ -73,10 +76,25 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): runs.delete(runId); }; - // LangChain never makes our run spans active, so each run starts under its parent run's span. - // With no span for the parent run (skipped or unseen), the active span stays the parent. + // LangChain never makes our run spans active, so a run starts under its nearest ancestor run span. Like core, + // an ignored span only parents when it is a root, which drops its subtree. The active span stays the parent + // if the ancestor run has no span, or if another span was opened since that run started. + const getParentRunSpan = (parentRunId: string | undefined, activeSpan: Span | undefined): Span | undefined => { + for (let id = parentRunId; id; id = runs.get(id)?.parentRunId) { + const span = spanMap.get(id); + if (!span || runs.get(id)?.activeSpan !== activeSpan) { + return undefined; + } + if (!spanIsIgnored(span) || getRootSpan(span) === span) { + return span; + } + } + return undefined; + }; + const startRunSpan = (runId: string, parentRunId: string | undefined, start: () => Span): void => { - const parentSpan = parentRunId ? spanMap.get(parentRunId) : undefined; + const activeSpan = getActiveSpan(); + const parentSpan = getParentRunSpan(parentRunId, activeSpan); const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); spanMap.set(runId, span); @@ -89,7 +107,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): if (conversationId && conversationId !== ownId) { span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } - runs.set(runId, { parentRunId, conversationId }); + runs.set(runId, { parentRunId, activeSpan, conversationId }); for (let id = parentRunId; conversationId && id; id = runs.get(id)?.parentRunId) { const run = runs.get(id); diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts index 0a7ae1888388..ff1fb0708a06 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts @@ -18,7 +18,7 @@ import { spanToStaticSpanJSON, startSpan, } from '@sentry/core'; -import type { Span } from '@sentry/core'; +import type { ClientOptions, Span } from '@sentry/core'; import { createLangChainCallbackHandler } from '../../../../src/ai/langchain'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; @@ -30,12 +30,13 @@ afterEach(() => { getMainCarrier().__SENTRY__ = undefined; }); -function setupClient(traceLifecycle: 'static' | 'stream' = 'static'): Span[] { +function setupClient(traceLifecycle: 'static' | 'stream' = 'static', options: Partial = {}): Span[] { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, traceLifecycle, + ...options, }), ); setCurrentClient(client); @@ -192,6 +193,56 @@ describe('LangChain callback handler run tree', () => { expect(spans['chat unknown']?.parent_span_id).toBe(spans['invoke_agent graph']?.span_id); }); + it('keeps a span opened inside a run as the parent of runs started under it', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + startSpan({ name: 'caller' }, () => { + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + startSpan({ name: 'fetch-context' }, () => { + handler.handleChainStart?.({}, {}, 'inner', 'outer', undefined, undefined, undefined, 'inner'); + handler.handleChatModelStart?.(chatModel, [[]], 'chat', 'inner'); + handler.handleLLMEnd?.({ generations: [] }, 'chat'); + handler.handleChainEnd?.({}, 'inner'); + }); + handler.handleChainEnd?.({}, 'outer'); + }); + + const spans = byName(endedSpans); + expect(spans['chain inner']?.parent_span_id).toBe(spans['fetch-context']?.span_id); + expect(spans['chat unknown']?.parent_span_id).toBe(spans['chain inner']?.span_id); + }); + + it('starts a run under the nearest ancestor run span that is not ignored', () => { + const endedSpans = setupClient('stream', { ignoreSpans: [{ name: 'invoke_agent inner' }] }); + const handler = createLangChainCallbackHandler(); + + startSpan({ name: 'caller' }, () => { + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleChainStart?.({}, {}, 'inner', 'outer', undefined, undefined, undefined, 'inner'); + handler.handleToolStart?.({}, '{}', 'tool', 'inner', undefined, undefined, 'lookup_weather'); + handler.handleToolEnd?.('sunny', 'tool'); + handler.handleChainEnd?.({}, 'inner'); + handler.handleChainEnd?.({}, 'outer'); + }); + + const spans = byName(endedSpans); + expect(spans['invoke_agent inner']).toBeUndefined(); + expect(spans['execute_tool lookup_weather']?.parent_span_id).toBe(spans['invoke_agent outer']?.span_id); + }); + + it('drops the runs under an ignored root run span, like core', () => { + const endedSpans = setupClient('stream', { ignoreSpans: [{ name: 'invoke_agent outer' }] }); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleToolStart?.({}, '{}', 'tool', 'outer', undefined, undefined, 'lookup_weather'); + handler.handleToolEnd?.('sunny', 'tool'); + handler.handleChainEnd?.({}, 'outer'); + + expect(endedSpans.map(span => spanToJSON(span).name)).toEqual([]); + }); + it('passes the conversation id down to child runs without one', () => { const endedSpans = setupClient(); const handler = createLangChainCallbackHandler(); From 99ecb71924766637ba0f4e4dde5ec7bb4ad3832e Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 15 Oct 2026 12:05:46 +0200 Subject: [PATCH 3/3] ref(server-utils): Track LangChain runs in one map spanMap, runs and decideRunIds always held the same run ids. Keep each run's span, parent, active span, conversation id and decide flag in one record, so the ancestor walks do one lookup per step and exitSpan clears a single entry. Co-Authored-By: Claude Opus 5.5 --- .../server-utils/src/ai/langchain/index.ts | 76 ++++++++++--------- .../tracing/langchain-invoke-agent.test.ts | 10 +-- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index df6acf626880..b255ed68ea3f 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -49,6 +49,15 @@ import { getInvocationParams, } from './utils'; +interface LangChainRun { + span: Span; + parentRunId?: string; + // The span active when the run started, to tell whether a span was opened inside the run since. + activeSpan?: Span; + conversationId?: string; + decide?: boolean; +} + /** * Creates a Sentry callback handler for LangChain * Returns a plain object that LangChain will call via duck-typing @@ -59,20 +68,17 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); // Internal state - single instance tracks all spans - const spanMap = new Map(); - const decideRunIds = new Set(); - const runs = new Map(); + const runs = new Map(); + const getRun = (runId: string | undefined): LangChainRun | undefined => (runId ? runs.get(runId) : undefined); /** * Exit a span and clean up */ const exitSpan = (runId: string): void => { - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { span.end(); } - spanMap.delete(runId); - decideRunIds.delete(runId); runs.delete(runId); }; @@ -80,43 +86,44 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): // an ignored span only parents when it is a root, which drops its subtree. The active span stays the parent // if the ancestor run has no span, or if another span was opened since that run started. const getParentRunSpan = (parentRunId: string | undefined, activeSpan: Span | undefined): Span | undefined => { - for (let id = parentRunId; id; id = runs.get(id)?.parentRunId) { - const span = spanMap.get(id); - if (!span || runs.get(id)?.activeSpan !== activeSpan) { + for (let run = getRun(parentRunId); run; run = getRun(run.parentRunId)) { + if (run.activeSpan !== activeSpan) { return undefined; } - if (!spanIsIgnored(span) || getRootSpan(span) === span) { - return span; + if (!spanIsIgnored(run.span) || getRootSpan(run.span) === run.span) { + return run.span; } } return undefined; }; - const startRunSpan = (runId: string, parentRunId: string | undefined, start: () => Span): void => { + const startRunSpan = (runId: string, parentRunId: string | undefined, start: () => Span): LangChainRun => { const activeSpan = getActiveSpan(); const parentSpan = getParentRunSpan(parentRunId, activeSpan); const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); - spanMap.set(runId, span); // `@langchain/core` >= 1.1.40 keeps `configurable` out of run metadata, so often only the chat span knows - // the conversation id. A span takes it from its parent, or hands it up to open ancestors without one. - // The own id is read off the span, since one from `Sentry.setConversationId()` is only set there. + // the conversation id. The own id is read off the span, since one from `Sentry.setConversationId()` is + // only set there. const ownId = spanToJSON(span).attributes[GEN_AI_CONVERSATION_ID]; - const parentConversationId = parentRunId ? runs.get(parentRunId)?.conversationId : undefined; - const conversationId = typeof ownId === 'string' ? ownId : parentConversationId; + const conversationId = typeof ownId === 'string' ? ownId : getRun(parentRunId)?.conversationId; if (conversationId && conversationId !== ownId) { span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } - runs.set(runId, { parentRunId, activeSpan, conversationId }); - for (let id = parentRunId; conversationId && id; id = runs.get(id)?.parentRunId) { - const run = runs.get(id); - if (!run || run.conversationId) { - break; + const run: LangChainRun = { span, parentRunId, activeSpan, conversationId }; + runs.set(runId, run); + if (conversationId) { + for ( + let ancestor = getRun(parentRunId); + ancestor && !ancestor.conversationId; + ancestor = getRun(ancestor.parentRunId) + ) { + ancestor.conversationId = conversationId; + ancestor.span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } - run.conversationId = conversationId; - spanMap.get(id)?.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } + return run; }; /** @@ -239,7 +246,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): _tags?: string[], _extraParams?: Record, ) { - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { const attributes = extractLlmResponseAttributes(output as LangChainLLMResult, recordOutputs); if (attributes) { @@ -253,7 +260,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleLLMError(_error: Error, runId: string) { // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and // do not record it. - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } @@ -273,8 +280,9 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ) { // A Jev call is a real model call, so it is recorded inside an agent too. if (isTypeSafeClassifier(chain)) { - startRunSpan(runId, parentRunId, () => startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs)); - decideRunIds.add(runId); + startRunSpan(runId, parentRunId, () => + startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs), + ).decide = true; return; } @@ -321,9 +329,10 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): // Chain End Handler handleChainEnd(outputs: unknown, runId: string) { - const span = spanMap.get(runId); + const run = runs.get(runId); + const span = run?.span; if (span?.isRecording()) { - if (decideRunIds.has(runId)) { + if (run?.decide) { addTypeSafeClassifierResponseAttributes(span, outputs, recordOutputs); } else if (recordOutputs) { span.setAttributes({ @@ -331,7 +340,6 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }); } } - // Also for a sampled-out span, which would otherwise stay tracked. exitSpan(runId); }, @@ -339,7 +347,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleChainError(_error: Error, runId: string) { // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and // do not record it. - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } @@ -389,7 +397,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): // Tool End Handler handleToolEnd(output: unknown, runId: string) { - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { if (recordOutputs) { // LangChain tools may return ToolMessage objects — extract the content @@ -408,7 +416,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleToolError(_error: Error, runId: string) { // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and // do not record it. - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts index ff1fb0708a06..d3a011cc3f32 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts @@ -303,12 +303,10 @@ describe('LangChain callback handler run tree', () => { thread_id: 'thread-b', }); handler.handleLLMEnd?.({ generations: [] }, 'b-chat'); - for (const runId of ['a-tool', 'b-tool']) { - handler.handleToolEnd?.('sunny', runId); - } - for (const runId of ['a', 'b']) { - handler.handleChainEnd?.({}, runId); - } + handler.handleToolEnd?.('sunny', 'a-tool'); + handler.handleToolEnd?.('sunny', 'b-tool'); + handler.handleChainEnd?.({}, 'a'); + handler.handleChainEnd?.({}, 'b'); const spans = byName(endedSpans); expect(spans['execute_tool tool_a']?.parent_span_id).toBe(spans['chain a']?.span_id);