Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ChatAnthropic } from '@langchain/anthropic';
import { RunnableLambda, RunnableSequence } from '@langchain/core/runnables';
import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
import { tool } from '@langchain/core/tools';
import * as Sentry from '@sentry/node';
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';

// LangGraph does this on import. Plain `@langchain/core` keeps a no-op store, so runs started
// without a config would see no ambient config at all.
AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new AsyncLocalStorage());

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_nested_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Sunny.' }],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 10, output_tokens: 5 },
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
const baseUrl = `http://localhost:${server.address().port}`;

const model = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0,
maxTokens: 50,
apiKey: 'mock-api-key',
clientOptions: { baseURL: baseUrl },
});

// The nested chain gets no config, so the conversation id and parent run only reach the model
// through LangChain's ambient runnable config.
const lookupWeather = tool(
async ({ city }) => {
const nested = RunnableSequence.from([RunnableLambda.from(c => `Weather in ${c}?`), model]).withConfig({
runName: 'nested_chain',
});
const result = await nested.invoke(city);
return result.content;
},
{
name: 'lookup_weather',
description: 'Look up the weather',
schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
},
);

const outer = RunnableLambda.from(input => lookupWeather.invoke({ city: input.city })).withConfig({
runName: 'outer',
});

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
await Sentry.startSpan({ op: 'function', name: 'auto' }, async () => {
const stream = await outer.stream({ city: 'Paris' }, { configurable: { thread_id: 'thread_auto' } });
for await (const _chunk of stream) {
// drain
}
});

await Sentry.startSpan({ op: 'function', name: 'handler' }, async () => {
const stream = await outer.stream(
{ city: 'Paris' },
{
callbacks: [Sentry.createLangChainCallbackHandler()],
configurable: { thread_id: 'thread_handler' },
},
);
for await (const _chunk of stream) {
// drain
}
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_DECIDE } from '@sentry/conventions/op';
import { GEN_AI_CHAT, GEN_AI_DECIDE } from '@sentry/conventions/op';
import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests';
Expand Down Expand Up @@ -236,6 +236,61 @@ describe('LangChain integration (v1)', () => {
},
);

createEsmAndCjsTests(
__dirname,
'scenario-nested-runtime-context.mjs',
'instrument.mjs',
(createRunner, test) => {
test('keeps the conversation id and trace for a model called from a tool without config', async () => {
const runner = createRunner().ignore('event');
const spansPromise = runner.collectStreamedSpansUntilSegment('main');

await runner.start().completed();

const spans = await spansPromise;
const byId = new Map(spans.map(span => [span.span_id, span]));
const ancestorNames = (span: (typeof spans)[number]): string[] => {
const names: string[] = [];
for (let p = byId.get(span.parent_span_id ?? ''); p; p = byId.get(p.parent_span_id ?? '')) {
names.push(p.name);
}
return names;
};
const chatSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT);

expect(chatSpans).toHaveLength(2);
expect(
chatSpans.map(span => [span.attributes[GEN_AI_CONVERSATION_ID]?.value, ancestorNames(span)[0]]).sort(),
).toEqual([
['thread_auto', 'auto'],
['thread_handler', 'handler'],
]);

const handlerRunSpans = spans.filter(
span =>
span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' &&
span.attributes[SENTRY_OP]?.value !== GEN_AI_CHAT,
);
expect(handlerRunSpans.map(span => span.name).sort()).toEqual([
'execute_tool lookup_weather',
'invoke_agent RunnableLambda',
'invoke_agent nested_chain',
'invoke_agent outer',
]);
for (const span of handlerRunSpans) {
expect(ancestorNames(span)).toContain('handler');
}
});
},
{
additionalDependencies: {
langchain: '^1.0.0',
'@langchain/core': '^1.0.0',
'@langchain/anthropic': '^1.0.0',
},
},
);

createEsmAndCjsTests(
__dirname,
'scenario-typesafe-classifier.mjs',
Expand Down
Loading