Skip to content

Commit afc7d67

Browse files
committed
record tool calls in langchain
1 parent 2a2e8cb commit afc7d67

7 files changed

Lines changed: 218 additions & 5 deletions

File tree

dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export default Sentry.withSentry(
1818
recordOutputs: false,
1919
});
2020

21+
// Create a second handler with recordOutputs enabled for tool_calls test
22+
const callbackHandlerWithOutputs = Sentry.createLangChainCallbackHandler({
23+
recordInputs: false,
24+
recordOutputs: true,
25+
});
26+
2127
// Test 1: Chat model invocation
2228
const chatModel = new MockChatModel({
2329
model: 'claude-3-5-sonnet-20241022',
@@ -29,7 +35,7 @@ export default Sentry.withSentry(
2935
callbacks: [callbackHandler],
3036
});
3137

32-
// Test 2: Chain invocation
38+
// Test 2: Chain invocation (without tool calls)
3339
const chain = new MockChain('my_test_chain');
3440
await chain.invoke(
3541
{ input: 'test input' },
@@ -44,6 +50,15 @@ export default Sentry.withSentry(
4450
callbacks: [callbackHandler],
4551
});
4652

53+
// Test 4: Chain invocation with tool calls (recordOutputs enabled)
54+
const chainWithToolCalls = new MockChain('chain_with_tool_calls', { includeToolCalls: true });
55+
await chainWithToolCalls.invoke(
56+
{ input: 'test input for tool calls' },
57+
{
58+
callbacks: [callbackHandlerWithOutputs],
59+
},
60+
);
61+
4762
return new Response(JSON.stringify({ success: true }));
4863
},
4964
},

dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,11 @@ export class MockChatModel {
132132
// Mock LangChain Chain
133133
export class MockChain {
134134
private _name: string;
135+
private _includeToolCalls: boolean;
135136

136-
public constructor(name: string) {
137+
public constructor(name: string, options?: { includeToolCalls?: boolean }) {
137138
this._name = name;
139+
this._includeToolCalls = options?.includeToolCalls ?? false;
138140
}
139141

140142
public async invoke(
@@ -151,7 +153,16 @@ export class MockChain {
151153
}
152154
}
153155

154-
const outputs = { result: 'Chain execution completed!' };
156+
const outputs = this._includeToolCalls
157+
? {
158+
result: 'Chain execution completed!',
159+
messages: [
160+
{
161+
tool_calls: [{ name: 'search_tool', args: { query: 'test query' } }],
162+
},
163+
],
164+
}
165+
: { result: 'Chain execution completed!' };
155166

156167
// Call handleChainEnd
157168
for (const callback of callbacks) {

dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ it('traces langchain chat model, chain, and tool invocations', async ({ signal }
3333
op: 'gen_ai.chat',
3434
origin: 'auto.ai.langchain',
3535
}),
36-
// Chain span
36+
// Chain span (without tool calls)
3737
expect.objectContaining({
3838
data: expect.objectContaining({
3939
'sentry.origin': 'auto.ai.langchain',
@@ -55,6 +55,19 @@ it('traces langchain chat model, chain, and tool invocations', async ({ signal }
5555
op: 'gen_ai.execute_tool',
5656
origin: 'auto.ai.langchain',
5757
}),
58+
// Chain span with tool calls (recordOutputs enabled)
59+
expect.objectContaining({
60+
data: expect.objectContaining({
61+
'sentry.origin': 'auto.ai.langchain',
62+
'sentry.op': 'gen_ai.invoke_agent',
63+
'langchain.chain.name': 'chain_with_tool_calls',
64+
'langchain.chain.outputs': expect.stringContaining('Chain execution completed'),
65+
'gen_ai.response.tool_calls': expect.stringContaining('search_tool'),
66+
}),
67+
description: 'chain chain_with_tool_calls',
68+
op: 'gen_ai.invoke_agent',
69+
origin: 'auto.ai.langchain',
70+
}),
5871
]),
5972
);
6073
})
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { RunnableLambda } from '@langchain/core/runnables';
2+
import * as Sentry from '@sentry/node';
3+
4+
async function run() {
5+
// Create callback handler with recordOutputs enabled
6+
const callbackHandler = Sentry.createLangChainCallbackHandler({
7+
recordInputs: true,
8+
recordOutputs: true,
9+
});
10+
11+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
12+
// Test 1: Chain without tool calls
13+
const simpleChain = new RunnableLambda({
14+
func: input => {
15+
return { result: `Processed: ${input.query}` };
16+
},
17+
}).withConfig({ runName: 'simple_chain' });
18+
19+
await simpleChain.invoke(
20+
{ query: 'Hello world' },
21+
{
22+
callbacks: [callbackHandler],
23+
},
24+
);
25+
26+
// Test 2: Chain with tool calls in output
27+
const chainWithToolCalls = new RunnableLambda({
28+
func: input => {
29+
return {
30+
result: `Processed with tools: ${input.query}`,
31+
messages: [
32+
{
33+
role: 'assistant',
34+
content: 'I will use the search tool',
35+
tool_calls: [
36+
{
37+
name: 'search',
38+
args: { query: input.query },
39+
id: 'tool_call_123',
40+
},
41+
{
42+
name: 'calculator',
43+
args: { expression: '2+2' },
44+
id: 'tool_call_456',
45+
},
46+
],
47+
},
48+
],
49+
};
50+
},
51+
}).withConfig({ runName: 'chain_with_tool_calls' });
52+
53+
await chainWithToolCalls.invoke(
54+
{ query: 'Search for something' },
55+
{
56+
callbacks: [callbackHandler],
57+
},
58+
);
59+
60+
// Test 3: Chain with direct tool_calls on output (alternative format)
61+
const chainWithDirectToolCalls = new RunnableLambda({
62+
func: input => {
63+
return {
64+
result: `Direct tool calls: ${input.query}`,
65+
tool_calls: [
66+
{
67+
name: 'weather',
68+
args: { location: 'San Francisco' },
69+
id: 'tool_call_789',
70+
},
71+
],
72+
};
73+
},
74+
}).withConfig({ runName: 'chain_with_direct_tool_calls' });
75+
76+
await chainWithDirectToolCalls.invoke(
77+
{ query: 'Get weather' },
78+
{
79+
callbacks: [callbackHandler],
80+
},
81+
);
82+
});
83+
84+
await Sentry.flush(2000);
85+
}
86+
87+
run();

dev-packages/node-integration-tests/suites/tracing/langchain/test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,60 @@ describe('LangChain integration', () => {
246246
},
247247
);
248248

249+
const EXPECTED_TRANSACTION_CHAIN_TOOL_CALLS = {
250+
transaction: 'main',
251+
spans: expect.arrayContaining([
252+
// Simple chain without tool calls (RunnableLambda reports name as "unknown_chain")
253+
expect.objectContaining({
254+
data: expect.objectContaining({
255+
'sentry.origin': 'auto.ai.langchain',
256+
'sentry.op': 'gen_ai.invoke_agent',
257+
'langchain.chain.inputs': expect.stringContaining('Hello world'),
258+
'langchain.chain.outputs': expect.stringContaining('Processed'),
259+
}),
260+
op: 'gen_ai.invoke_agent',
261+
origin: 'auto.ai.langchain',
262+
status: 'ok',
263+
}),
264+
// Chain with tool calls in messages format
265+
expect.objectContaining({
266+
data: expect.objectContaining({
267+
'sentry.origin': 'auto.ai.langchain',
268+
'sentry.op': 'gen_ai.invoke_agent',
269+
'langchain.chain.outputs': expect.stringContaining('Processed with tools'),
270+
// This is the key attribute we're testing
271+
'gen_ai.response.tool_calls': expect.stringContaining('search'),
272+
}),
273+
op: 'gen_ai.invoke_agent',
274+
origin: 'auto.ai.langchain',
275+
status: 'ok',
276+
}),
277+
// Chain with direct tool_calls on output
278+
expect.objectContaining({
279+
data: expect.objectContaining({
280+
'sentry.origin': 'auto.ai.langchain',
281+
'sentry.op': 'gen_ai.invoke_agent',
282+
'langchain.chain.outputs': expect.stringContaining('Direct tool calls'),
283+
// This tests the alternative format (tool_calls directly on output)
284+
'gen_ai.response.tool_calls': expect.stringContaining('weather'),
285+
}),
286+
op: 'gen_ai.invoke_agent',
287+
origin: 'auto.ai.langchain',
288+
status: 'ok',
289+
}),
290+
]),
291+
};
292+
293+
createEsmAndCjsTests(__dirname, 'scenario-chain-tool-calls.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
294+
test('creates langchain chain spans with tool calls', async () => {
295+
await createRunner()
296+
.ignore('event')
297+
.expect({ transaction: EXPECTED_TRANSACTION_CHAIN_TOOL_CALLS })
298+
.start()
299+
.completed();
300+
});
301+
});
302+
249303
createEsmAndCjsTests(
250304
__dirname,
251305
'scenario-openai-before-langchain.mjs',

packages/core/src/tracing/langchain/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '
33
import { SPAN_STATUS_ERROR } from '../../tracing';
44
import { startSpanManual } from '../../tracing/trace';
55
import type { Span, SpanAttributeValue } from '../../types-hoist/span';
6-
import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE } from '../ai/gen-ai-attributes';
6+
import {
7+
GEN_AI_OPERATION_NAME_ATTRIBUTE,
8+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
9+
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
10+
} from '../ai/gen-ai-attributes';
711
import { LANGCHAIN_ORIGIN } from './constants';
812
import type {
913
LangChainCallbackHandler,
@@ -16,6 +20,7 @@ import {
1620
extractChatModelRequestAttributes,
1721
extractLLMRequestAttributes,
1822
extractLlmResponseAttributes,
23+
extractToolCallsFromChainOutput,
1924
getInvocationParams,
2025
} from './utils';
2126

@@ -215,6 +220,12 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
215220
span.setAttributes({
216221
'langchain.chain.outputs': JSON.stringify(outputs),
217222
});
223+
224+
// Extract tool calls from chain outputs
225+
const toolCalls = extractToolCallsFromChainOutput(outputs);
226+
if (toolCalls && toolCalls.length > 0) {
227+
span.setAttribute(GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, JSON.stringify(toolCalls));
228+
}
218229
}
219230
exitSpan(runId);
220231
}

packages/core/src/tracing/langchain/utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,28 @@ function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record
318318
}
319319
}
320320

321+
/**
322+
* Extracts tool calls from chain outputs.
323+
* Handles: { messages: [{ tool_calls }] }, { output: { messages } }, { tool_calls }
324+
*/
325+
export function extractToolCallsFromChainOutput(outputs: unknown): unknown[] | null {
326+
if (!outputs || typeof outputs !== 'object') return null;
327+
328+
const toolCalls: unknown[] = [];
329+
const out = outputs as Record<string, unknown>;
330+
const messages = out.messages ?? (out.output as Record<string, unknown> | undefined)?.messages;
331+
332+
if (Array.isArray(messages)) {
333+
for (const msg of messages) {
334+
const calls = (msg as Record<string, unknown> | null)?.tool_calls;
335+
if (Array.isArray(calls)) toolCalls.push(...calls);
336+
}
337+
}
338+
if (Array.isArray(out.tool_calls)) toolCalls.push(...out.tool_calls);
339+
340+
return toolCalls.length > 0 ? toolCalls : null;
341+
}
342+
321343
/**
322344
* Adds token usage attributes, supporting both OpenAI (`tokenUsage`) and Anthropic (`usage`) formats.
323345
* - Preserve zero values (0 tokens) by avoiding truthy checks.

0 commit comments

Comments
 (0)