Skip to content

Commit 99ecb71

Browse files
RulaKhaledclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 22d34e9 commit 99ecb71

2 files changed

Lines changed: 46 additions & 40 deletions

File tree

‎packages/server-utils/src/ai/langchain/index.ts‎

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

‎packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,10 @@ describe('LangChain callback handler run tree', () => {
303303
thread_id: 'thread-b',
304304
});
305305
handler.handleLLMEnd?.({ generations: [] }, 'b-chat');
306-
for (const runId of ['a-tool', 'b-tool']) {
307-
handler.handleToolEnd?.('sunny', runId);
308-
}
309-
for (const runId of ['a', 'b']) {
310-
handler.handleChainEnd?.({}, runId);
311-
}
306+
handler.handleToolEnd?.('sunny', 'a-tool');
307+
handler.handleToolEnd?.('sunny', 'b-tool');
308+
handler.handleChainEnd?.({}, 'a');
309+
handler.handleChainEnd?.({}, 'b');
312310

313311
const spans = byName(endedSpans);
314312
expect(spans['execute_tool tool_a']?.parent_span_id).toBe(spans['chain a']?.span_id);

0 commit comments

Comments
 (0)