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..b255ed68ea3f 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -1,7 +1,19 @@ /* eslint-disable max-lines */ -import { getClient, hasSpanStreamingEnabled, SPAN_STATUS_ERROR, startSpanManual, stringify } from '@sentry/core'; +import { + getActiveSpan, + getClient, + getRootSpan, + hasSpanStreamingEnabled, + SPAN_STATUS_ERROR, + spanIsIgnored, + 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, @@ -37,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 @@ -47,19 +68,62 @@ 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 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); + }; + + // 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 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): LangChainRun => { + const activeSpan = getActiveSpan(); + const parentSpan = getParentRunSpan(parentRunId, activeSpan); + const span = parentSpan ? withActiveSpan(parentSpan, start) : start(); + + // `@langchain/core` >= 1.1.40 keeps `configurable` out of run metadata, so often only the chat span knows + // 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 conversationId = typeof ownId === 'string' ? ownId : getRun(parentRunId)?.conversationId; + if (conversationId && conversationId !== ownId) { + span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); + } + + 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); + } + } + return run; }; /** @@ -91,7 +155,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, prompts: string[], runId: string, - _parentRunId?: string, + parentRunId?: string, _extraParams?: Record, tags?: string[], metadata?: Record, @@ -110,8 +174,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 +187,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SENTRY_OP]: GEN_AI_CHAT, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -136,7 +196,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm: unknown, messages: unknown, runId: string, - _parentRunId?: string, + parentRunId?: string, extraParams?: Record, tags?: string[], metadata?: Record, @@ -161,8 +221,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 +234,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [SENTRY_OP]: GEN_AI_CHAT, }, - }, - span => { - spanMap.set(runId, span); - return span; - }, + }), ); }, @@ -190,25 +246,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 @@ -216,7 +272,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): chain: { name?: string }, inputs: Record, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, _runType?: string, @@ -224,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)) { - spanMap.set(runId, startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs)); - decideRunIds.add(runId); + startRunSpan(runId, parentRunId, () => + startTypeSafeClassifierSpan(chain, inputs, metadata, recordInputs), + ).decide = true; return; } @@ -253,8 +310,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,19 +323,16 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): ...attributes, [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 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({ @@ -286,7 +340,6 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }); } } - // Also for a sampled-out span, which would otherwise stay tracked. exitSpan(runId); }, @@ -294,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' }); } @@ -306,7 +359,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): tool: { name?: string }, input: string, runId: string, - _parentRunId?: string, + parentRunId?: string, _tags?: string[], metadata?: Record, runName?: string, @@ -331,24 +384,20 @@ 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; - }, + }), ); }, // 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 @@ -359,19 +408,19 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): [GEN_AI_TOOL_CALL_RESULT]: stringify(content, String), }); } - 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/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..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 @@ -1,42 +1,53 @@ 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 type { Span } from '@sentry/core'; +import { + conversationIdIntegration, + getClient, + getMainCarrier, + setConversationId, + setCurrentClient, + spanToJSON, + spanToStaticSpanJSON, + startSpan, +} from '@sentry/core'; +import type { ClientOptions, 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', options: Partial = {}): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + ...options, + }), + ); + 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 +139,202 @@ 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('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(); + + 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'); + 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); + 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', + }); + }); +});