From bac93e37ea002796e3f01c7e2205af42c1b0e400 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 13 Oct 2026 14:14:34 +0200 Subject: [PATCH 1/2] test(node): Cover LangChain model calls made from a tool without config Co-Authored-By: Claude Opus 5.5 --- .../v1/scenario-nested-runtime-context.mjs | 96 +++++++++++++++++++ .../suites/tracing/langchain/v1/test.ts | 55 +++++++++++ 2 files changed, 151 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs new file mode 100644 index 000000000000..a5bcb97adc9a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs @@ -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(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index f6de0aecca94..d2f250b9f25b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -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', From a46f2eb8fae2ce3e0d75e6eb8b79bdd5a2799d90 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 13 Oct 2026 14:32:47 +0200 Subject: [PATCH 2/2] test(node): Use GEN_AI_CHAT constant in nested runtime context test Co-Authored-By: Claude Opus 5.5 --- .../suites/tracing/langchain/v1/test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index d2f250b9f25b..4260da49bdc4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -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'; @@ -256,7 +256,7 @@ describe('LangChain integration (v1)', () => { } return names; }; - const chatSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.chat'); + const chatSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT); expect(chatSpans).toHaveLength(2); expect( @@ -269,7 +269,7 @@ describe('LangChain integration (v1)', () => { const handlerRunSpans = spans.filter( span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' && - span.attributes[SENTRY_OP]?.value !== 'gen_ai.chat', + span.attributes[SENTRY_OP]?.value !== GEN_AI_CHAT, ); expect(handlerRunSpans.map(span => span.name).sort()).toEqual([ 'execute_tool lookup_weather',