Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 60 additions & 52 deletions packages/core/src/tracing/langchain/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<string, Span>();
const runs = new Map<string, LangChainRun>();
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 });
};

/**
Expand Down Expand Up @@ -82,7 +106,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
llm: unknown,
prompts: string[],
runId: string,
_parentRunId?: string,
parentRunId?: string,
_extraParams?: Record<string, unknown>,
tags?: string[],
metadata?: Record<string, unknown>,
Expand All @@ -100,20 +124,16 @@ 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: {
...getAgentNameFromMetadata(metadata),
...attributes,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat',
},
},
span => {
spanMap.set(runId, span);
return span;
},
}),
);
},

Expand All @@ -122,7 +142,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
llm: unknown,
messages: unknown,
runId: string,
_parentRunId?: string,
parentRunId?: string,
extraParams?: Record<string, unknown>,
tags?: string[],
metadata?: Record<string, unknown>,
Expand All @@ -146,20 +166,16 @@ 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: {
...getAgentNameFromMetadata(metadata),
...attributes,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat',
},
},
span => {
spanMap.set(runId, span);
return span;
},
}),
);
},

Expand All @@ -171,33 +187,33 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
_tags?: string[],
_extraParams?: Record<string, unknown>,
) {
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
handleChainStart(
chain: { name?: string },
inputs: Record<string, unknown>,
runId: string,
_parentRunId?: string,
parentRunId?: string,
_tags?: string[],
metadata?: Record<string, unknown>,
_runType?: string,
Expand All @@ -220,53 +236,49 @@ 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) {
span.setAttributes({
'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
handleToolStart(
tool: { name?: string },
input: string,
runId: string,
_parentRunId?: string,
parentRunId?: string,
_tags?: string[],
metadata?: Record<string, unknown>,
runName?: string,
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading