From 2adc0fcd996a62dfb152af6b80142cfb7ea50ccd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 19 Oct 2026 11:11:24 +0200 Subject: [PATCH] fix(v10/server-utils): Nest LangChain run spans under their parent run Backport of: #25364 Co-Authored-By: GPT-6 --- packages/core/src/tracing/langchain/index.ts | 112 ++++++++------- .../lib/tracing/langchain-run-tree.test.ts | 133 ++++++++++++++++++ 2 files changed, 193 insertions(+), 52 deletions(-) create mode 100644 packages/core/test/lib/tracing/langchain-run-tree.test.ts diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 3443c9e55f86..5379bb57682f 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -1,7 +1,8 @@ /* eslint-disable max-lines */ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; -import { startSpanManual } from '../../tracing/trace'; +import { spanIsIgnored, startInactiveSpan, withActiveSpan } from '../../tracing/trace'; +import { getActiveSpan, getRootSpan } from '../../utils/spanUtils'; import type { Span, SpanAttributeValue } from '../../types/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -29,6 +30,12 @@ import { getInvocationParams, } from './utils'; +interface LangChainRun { + span: Span; + parentRunId?: string; + activeSpan?: Span; +} + /** * Creates a Sentry callback handler for LangChain * Returns a plain object that LangChain will call via duck-typing @@ -39,18 +46,35 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); const enableTruncation = shouldEnableTruncation(options.enableTruncation); - // Internal state - single instance tracks all spans - const spanMap = 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); } + runs.delete(runId); + }; + + // Preserve an active span opened inside a run, and skip ignored ancestor runs as core does. + const getParentRunSpan = (parentRunId: string | undefined, activeSpan: Span | undefined): Span | undefined => { + for (let run = getRun(parentRunId); run; run = getRun(run.parentRunId)) { + if (run.activeSpan !== activeSpan) { + return undefined; + } + 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 activeSpan = getActiveSpan(); + const parentSpan = getParentRunSpan(parentRunId, activeSpan); + const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); + runs.set(runId, { span, parentRunId, activeSpan }); }; /** @@ -82,7 +106,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, prompts: string[], runId: string, - _parentRunId?: string, + parentRunId?: string, _extraParams?: Record, tags?: string[], metadata?: Record, @@ -100,8 +124,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { @@ -109,11 +133,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -122,7 +142,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, messages: unknown, runId: string, - _parentRunId?: string, + parentRunId?: string, extraParams?: Record, tags?: string[], metadata?: Record, @@ -146,8 +166,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { @@ -155,11 +175,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -171,25 +187,25 @@ 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) { span.setAttributes(attributes); } - exitSpan(runId); } + exitSpan(runId); }, // LLM Error Handler - note: handleLLMError with capital LLM 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' }); - exitSpan(runId); } + exitSpan(runId); }, // Chain Start Handler @@ -197,7 +213,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): chain: { name?: string }, inputs: Record, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, _runType?: string, @@ -220,25 +236,21 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): attributes['langchain.chain.inputs'] = JSON.stringify(inputs); } - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ name: `chain ${chainName}`, op: 'gen_ai.invoke_agent', attributes: { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, // Chain End Handler handleChainEnd(outputs: unknown, runId: string) { - const span = spanMap.get(runId); + const span = runs.get(runId)?.span; if (span?.isRecording()) { // Add outputs if recordOutputs is enabled if (recordOutputs) { @@ -246,19 +258,19 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): 'langchain.chain.outputs': JSON.stringify(outputs), }); } - exitSpan(runId); } + exitSpan(runId); }, // Chain Error Handler 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' }); - exitSpan(runId); } + exitSpan(runId); }, // Tool Start Handler @@ -266,7 +278,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): tool: { name?: string }, input: string, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, runName?: string, @@ -290,25 +302,21 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): attributes[GEN_AI_TOOL_INPUT_ATTRIBUTE] = input; } - startSpanManual( - { + startRunSpan(runId, parentRunId, () => + startInactiveSpan({ name: `execute_tool ${toolName}`, op: 'gen_ai.execute_tool', attributes: { ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, // 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 @@ -319,19 +327,19 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: typeof content === 'string' ? content : JSON.stringify(content), }); } - exitSpan(runId); } + exitSpan(runId); }, // Tool Error Handler 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' }); - exitSpan(runId); } + exitSpan(runId); }, // LangChain BaseCallbackHandler required methods diff --git a/packages/core/test/lib/tracing/langchain-run-tree.test.ts b/packages/core/test/lib/tracing/langchain-run-tree.test.ts new file mode 100644 index 000000000000..32f419854a22 --- /dev/null +++ b/packages/core/test/lib/tracing/langchain-run-tree.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getMainCarrier } from '../../../src/carrier'; +import { setCurrentClient } from '../../../src/sdk'; +import { startSpan } from '../../../src/tracing/trace'; +import { createLangChainCallbackHandler } from '../../../src/tracing/langchain'; +import { spanToJSON } from '../../../src/utils/spanUtils'; +import type { Span } from '../../../src/types/span'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; +}); +afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; +}); + +function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; +} + +describe('LangChain callback handler run tree', () => { + function byName(spans: Span[]): Record> { + return Object.fromEntries(spans.map(spanToJSON).map(json => [json.description!, json])); + } + + 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('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('keeps run trees that interleave on one handler apart', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + + handler.handleChainStart?.({}, {}, 'a', undefined, undefined, undefined, 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'); + handler.handleLLMEnd?.({ generations: [] }, 'b-chat'); + handler.handleToolEnd?.('sunny', 'a-tool'); + handler.handleToolEnd?.('sunny', 'b-tool'); + handler.handleChainEnd?.({}, 'a'); + handler.handleChainEnd?.({}, 'b'); + + expect(endedSpans).toHaveLength(5); + 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); + }); + + it('keeps runs started without an active span in one trace', () => { + const endedSpans = setupClient(); + const handler = createLangChainCallbackHandler(); + handler.handleChainStart?.({}, {}, 'outer', undefined, undefined, undefined, undefined, 'outer'); + handler.handleLLMStart?.(chatModel, ['hello'], 'llm', 'outer'); + handler.handleLLMEnd?.({ generations: [] }, 'llm'); + handler.handleChainEnd?.({}, 'outer'); + const spans = byName(endedSpans); + expect(endedSpans).toHaveLength(2); + expect(spans['chat unknown']?.parent_span_id).toBe(spans['chain outer']?.span_id); + expect(new Set(endedSpans.map(span => span.spanContext().traceId)).size).toBe(1); + }); +});