@@ -49,6 +49,15 @@ import {
4949 getInvocationParams ,
5050} from './utils' ;
5151
52+ interface LangChainRun {
53+ span : Span ;
54+ parentRunId ?: string ;
55+ // The span active when the run started, to tell whether a span was opened inside the run since.
56+ activeSpan ?: Span ;
57+ conversationId ?: string ;
58+ decide ?: boolean ;
59+ }
60+
5261/**
5362 * Creates a Sentry callback handler for LangChain
5463 * Returns a plain object that LangChain will call via duck-typing
@@ -59,64 +68,62 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
5968 const { recordInputs, recordOutputs } = resolveAIRecordingOptions ( options ) ;
6069
6170 // Internal state - single instance tracks all spans
62- const spanMap = new Map < string , Span > ( ) ;
63- const decideRunIds = new Set < string > ( ) ;
64- const runs = new Map < string , { parentRunId ?: string ; activeSpan ?: Span ; conversationId ?: string } > ( ) ;
71+ const runs = new Map < string , LangChainRun > ( ) ;
72+ const getRun = ( runId : string | undefined ) : LangChainRun | undefined => ( runId ? runs . get ( runId ) : undefined ) ;
6573
6674 /**
6775 * Exit a span and clean up
6876 */
6977 const exitSpan = ( runId : string ) : void => {
70- const span = spanMap . get ( runId ) ;
78+ const span = runs . get ( runId ) ?. span ;
7179 if ( span ?. isRecording ( ) ) {
7280 span . end ( ) ;
7381 }
74- spanMap . delete ( runId ) ;
75- decideRunIds . delete ( runId ) ;
7682 runs . delete ( runId ) ;
7783 } ;
7884
7985 // LangChain never makes our run spans active, so a run starts under its nearest ancestor run span. Like core,
8086 // an ignored span only parents when it is a root, which drops its subtree. The active span stays the parent
8187 // if the ancestor run has no span, or if another span was opened since that run started.
8288 const getParentRunSpan = ( parentRunId : string | undefined , activeSpan : Span | undefined ) : Span | undefined => {
83- for ( let id = parentRunId ; id ; id = runs . get ( id ) ?. parentRunId ) {
84- const span = spanMap . get ( id ) ;
85- if ( ! span || runs . get ( id ) ?. activeSpan !== activeSpan ) {
89+ for ( let run = getRun ( parentRunId ) ; run ; run = getRun ( run . parentRunId ) ) {
90+ if ( run . activeSpan !== activeSpan ) {
8691 return undefined ;
8792 }
88- if ( ! spanIsIgnored ( span ) || getRootSpan ( span ) === span ) {
89- return span ;
93+ if ( ! spanIsIgnored ( run . span ) || getRootSpan ( run . span ) === run . span ) {
94+ return run . span ;
9095 }
9196 }
9297 return undefined ;
9398 } ;
9499
95- const startRunSpan = ( runId : string , parentRunId : string | undefined , start : ( ) => Span ) : void => {
100+ const startRunSpan = ( runId : string , parentRunId : string | undefined , start : ( ) => Span ) : LangChainRun => {
96101 const activeSpan = getActiveSpan ( ) ;
97102 const parentSpan = getParentRunSpan ( parentRunId , activeSpan ) ;
98103 const span = parentSpan ? withActiveSpan ( parentSpan , start ) : start ( ) ;
99- spanMap . set ( runId , span ) ;
100104
101105 // `@langchain/core` >= 1.1.40 keeps `configurable` out of run metadata, so often only the chat span knows
102- // the conversation id. A span takes it from its parent, or hands it up to open ancestors without one.
103- // The own id is read off the span, since one from `Sentry.setConversationId()` is only set there.
106+ // the conversation id. The own id is read off the span, since one from `Sentry.setConversationId()` is
107+ // only set there.
104108 const ownId = spanToJSON ( span ) . attributes [ GEN_AI_CONVERSATION_ID ] ;
105- const parentConversationId = parentRunId ? runs . get ( parentRunId ) ?. conversationId : undefined ;
106- const conversationId = typeof ownId === 'string' ? ownId : parentConversationId ;
109+ const conversationId = typeof ownId === 'string' ? ownId : getRun ( parentRunId ) ?. conversationId ;
107110 if ( conversationId && conversationId !== ownId ) {
108111 span . setAttribute ( GEN_AI_CONVERSATION_ID , conversationId ) ;
109112 }
110- runs . set ( runId , { parentRunId, activeSpan, conversationId } ) ;
111113
112- for ( let id = parentRunId ; conversationId && id ; id = runs . get ( id ) ?. parentRunId ) {
113- const run = runs . get ( id ) ;
114- if ( ! run || run . conversationId ) {
115- break ;
114+ const run : LangChainRun = { span, parentRunId, activeSpan, conversationId } ;
115+ runs . set ( runId , run ) ;
116+ if ( conversationId ) {
117+ for (
118+ let ancestor = getRun ( parentRunId ) ;
119+ ancestor && ! ancestor . conversationId ;
120+ ancestor = getRun ( ancestor . parentRunId )
121+ ) {
122+ ancestor . conversationId = conversationId ;
123+ ancestor . span . setAttribute ( GEN_AI_CONVERSATION_ID , conversationId ) ;
116124 }
117- run . conversationId = conversationId ;
118- spanMap . get ( id ) ?. setAttribute ( GEN_AI_CONVERSATION_ID , conversationId ) ;
119125 }
126+ return run ;
120127 } ;
121128
122129 /**
@@ -239,7 +246,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
239246 _tags ?: string [ ] ,
240247 _extraParams ?: Record < string , unknown > ,
241248 ) {
242- const span = spanMap . get ( runId ) ;
249+ const span = runs . get ( runId ) ?. span ;
243250 if ( span ?. isRecording ( ) ) {
244251 const attributes = extractLlmResponseAttributes ( output as LangChainLLMResult , recordOutputs ) ;
245252 if ( attributes ) {
@@ -253,7 +260,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
253260 handleLLMError ( _error : Error , runId : string ) {
254261 // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and
255262 // do not record it.
256- const span = spanMap . get ( runId ) ;
263+ const span = runs . get ( runId ) ?. span ;
257264 if ( span ?. isRecording ( ) ) {
258265 span . setStatus ( { code : SPAN_STATUS_ERROR , message : 'internal_error' } ) ;
259266 }
@@ -273,8 +280,9 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
273280 ) {
274281 // A Jev call is a real model call, so it is recorded inside an agent too.
275282 if ( isTypeSafeClassifier ( chain ) ) {
276- startRunSpan ( runId , parentRunId , ( ) => startTypeSafeClassifierSpan ( chain , inputs , metadata , recordInputs ) ) ;
277- decideRunIds . add ( runId ) ;
283+ startRunSpan ( runId , parentRunId , ( ) =>
284+ startTypeSafeClassifierSpan ( chain , inputs , metadata , recordInputs ) ,
285+ ) . decide = true ;
278286 return ;
279287 }
280288
@@ -321,25 +329,25 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
321329
322330 // Chain End Handler
323331 handleChainEnd ( outputs : unknown , runId : string ) {
324- const span = spanMap . get ( runId ) ;
332+ const run = runs . get ( runId ) ;
333+ const span = run ?. span ;
325334 if ( span ?. isRecording ( ) ) {
326- if ( decideRunIds . has ( runId ) ) {
335+ if ( run ?. decide ) {
327336 addTypeSafeClassifierResponseAttributes ( span , outputs , recordOutputs ) ;
328337 } else if ( recordOutputs ) {
329338 span . setAttributes ( {
330339 'langchain.chain.outputs' : JSON . stringify ( outputs ) ,
331340 } ) ;
332341 }
333342 }
334- // Also for a sampled-out span, which would otherwise stay tracked.
335343 exitSpan ( runId ) ;
336344 } ,
337345
338346 // Chain Error Handler
339347 handleChainError ( _error : Error , runId : string ) {
340348 // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and
341349 // do not record it.
342- const span = spanMap . get ( runId ) ;
350+ const span = runs . get ( runId ) ?. span ;
343351 if ( span ?. isRecording ( ) ) {
344352 span . setStatus ( { code : SPAN_STATUS_ERROR , message : 'internal_error' } ) ;
345353 }
@@ -389,7 +397,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
389397
390398 // Tool End Handler
391399 handleToolEnd ( output : unknown , runId : string ) {
392- const span = spanMap . get ( runId ) ;
400+ const span = runs . get ( runId ) ?. span ;
393401 if ( span ?. isRecording ( ) ) {
394402 if ( recordOutputs ) {
395403 // LangChain tools may return ToolMessage objects — extract the content
@@ -408,7 +416,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
408416 handleToolError ( _error : Error , runId : string ) {
409417 // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and
410418 // do not record it.
411- const span = spanMap . get ( runId ) ;
419+ const span = runs . get ( runId ) ?. span ;
412420 if ( span ?. isRecording ( ) ) {
413421 span . setStatus ( { code : SPAN_STATUS_ERROR , message : 'internal_error' } ) ;
414422 }
0 commit comments