From 58819f0ea2e7c81453b177e08961157fb86be665 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 25 Aug 2026 13:20:16 +0300 Subject: [PATCH] fix(core): Bind AI provider skips to the isolation scope The AI provider skip registry was a module-global Set that the Node and Cloudflare clients cleared once per client. Skips are registered lazily by higher-level integrations at call time (LangChain marking its providers, Vercel AI marking Workers AI), so a single such call suppressed the spans of direct provider calls in every later request served by the same client: for the whole process on Node, for the whole isolate on Cloudflare. The registry is now keyed on the isolation scope. A skip registered inside an invocation applies to that invocation only, one registered outside any invocation still applies everywhere. The per-client clear in the Node and Cloudflare clients is no longer needed. Adds a Node integration test that sends a LangChain request followed by a direct Anthropic request on one server and asserts the second one keeps its span. Co-Authored-By: Claude Fable 5 --- ...cenario-direct-after-langchain-express.mjs | 58 +++++++++++++ .../suites/tracing/langchain/test.ts | 36 +++++++++ packages/cloudflare/src/client.ts | 11 --- packages/core/src/utils/ai/providerSkip.ts | 45 +++++++---- .../test/lib/utils/ai/providerSkip.test.ts | 81 ++++++++++++++++++- packages/node/src/sdk/client.ts | 10 --- 6 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-direct-after-langchain-express.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-direct-after-langchain-express.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-direct-after-langchain-express.mjs new file mode 100644 index 000000000000..9bf8995af3b9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-direct-after-langchain-express.mjs @@ -0,0 +1,58 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_test123', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Mock response from Anthropic!' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 15 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +// No top-level await: the scenario also runs transpiled to CJS. +startMockAnthropicServer().then(mockServer => { + const baseURL = `http://localhost:${mockServer.address().port}`; + + const app = express(); + + app.get('/langchain', async (_req, res) => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { baseURL }, + }); + await model.invoke('LangChain Anthropic call'); + res.send({ message: 'OK' }); + }); + + app.get('/direct', async (_req, res) => { + const client = new Anthropic({ apiKey: 'mock-api-key', baseURL }); + await client.messages.create({ + model: 'claude-3-5-sonnet-20241022', + messages: [{ role: 'user', content: 'Direct Anthropic call' }], + max_tokens: 100, + }); + res.send({ message: 'OK' }); + }); + + Sentry.setupExpressErrorHandler(app); + + startExpressServerAndSendPortToRunner(app); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index e1ead1a89fe2..8405a7774ddd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -229,6 +229,42 @@ describe('LangChain integration', () => { }); }); + createEsmAndCjsTests( + __dirname, + 'scenario-direct-after-langchain-express.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('keeps instrumenting direct provider calls in requests after a LangChain request', async () => { + const runner = createRunner() + // The transaction and span envelopes of a request can arrive in either order. + .unordered() + .expect({ transaction: { transaction: 'GET /langchain' } }) + .expect({ + span: container => { + expect(container.items).toHaveLength(1); + expect(container.items[0]!.name).toBe('chat claude-3-5-sonnet-20241022'); + expect(container.items[0]!.attributes['sentry.origin'].value).toBe('auto.ai.langchain'); + }, + }) + .expect({ transaction: { transaction: 'GET /direct' } }) + .expect({ + span: container => { + expect(container.items).toHaveLength(1); + expect(container.items[0]!.name).toBe('chat claude-3-5-sonnet-20241022'); + expect(container.items[0]!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); + }, + }) + .start(); + + // The LangChain request marks Anthropic as skipped for its own invocation only; the direct + // call of the next request must still get its span. + await runner.makeRequest('get', '/langchain'); + await runner.makeRequest('get', '/direct'); + await runner.completed(); + }); + }, + ); + createEsmAndCjsTests( __dirname, 'scenario-system-instructions.mjs', diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 85e47fbee3d7..9e8f4cd3ea41 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,6 +1,5 @@ import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; import { - _INTERNAL_clearAiProviderSkips, applySdkMetadata, debug, ServerRuntimeClient, @@ -146,16 +145,6 @@ export class CloudflareClient extends ServerRuntimeClient { (this as unknown as { _flushLock: ReturnType | void })._flushLock = undefined; } - /** @inheritDoc */ - protected override _setupIntegrations(): void { - // Clear AI provider skip registrations before setting up integrations. - // The registry is module-global and Cloudflare calls `init()` per request, so without this a - // single `ai` SDK call would suppress direct `env.AI.run` spans for the rest of the isolate's - // life. Mirrors the same reset in the Node client. - _INTERNAL_clearAiProviderSkips(); - super._setupIntegrations(); - } - /** * Resets the span completion promise and resolve function. */ diff --git a/packages/core/src/utils/ai/providerSkip.ts b/packages/core/src/utils/ai/providerSkip.ts index 0b7ca2a5c3bc..123e6c387cfa 100644 --- a/packages/core/src/utils/ai/providerSkip.ts +++ b/packages/core/src/utils/ai/providerSkip.ts @@ -1,16 +1,25 @@ +import { getIsolationScope } from '../../currentScopes'; import { DEBUG_BUILD } from '../../debug-build'; +import { getDefaultIsolationScope } from '../../defaultScopes'; +import type { Scope } from '../../scope'; import { debug } from '../debug-logger'; /** - * Registry tracking which AI provider modules should skip instrumentation wrapping. + * AI provider modules that should skip instrumentation wrapping, per isolation scope. * - * This prevents duplicate spans when a higher-level integration (like LangChain) - * already instruments AI providers at a higher abstraction level. + * Skips are registered lazily by a higher-level integration (like LangChain) once it drives a + * provider, so they are bound to the invocation that registered them. A module-global set would + * outlive the invocation on runtimes where a client serves many invocations (Cloudflare isolates, + * Node processes) and suppress spans for direct provider calls made by later, unrelated invocations. */ -const SKIPPED_AI_PROVIDERS = new Set(); +const SKIPPED_AI_PROVIDERS = new WeakMap>(); + +function getSkips(scope: Scope): Set | undefined { + return SKIPPED_AI_PROVIDERS.get(scope); +} /** - * Mark AI provider modules to skip instrumentation wrapping. + * Mark AI provider modules to skip instrumentation wrapping for the current isolation scope. * * This prevents duplicate spans when a higher-level integration (like LangChain) * already instruments AI providers at a higher abstraction level. @@ -25,15 +34,25 @@ const SKIPPED_AI_PROVIDERS = new Set(); * ``` */ export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void { - modules.forEach(module => { - SKIPPED_AI_PROVIDERS.add(module); + const scope = getIsolationScope(); + let skips = getSkips(scope); + if (!skips) { + skips = new Set(); + SKIPPED_AI_PROVIDERS.set(scope, skips); + } + + for (const module of modules) { + skips.add(module); DEBUG_BUILD && debug.log(`AI provider "${module}" wrapping will be skipped`); - }); + } } /** * Check if an AI provider module should skip instrumentation wrapping. * + * A skip registered inside an invocation applies to that invocation; one registered outside any + * invocation (on the default isolation scope) applies everywhere. + * * @internal * @param module - The npm module name (e.g., '@anthropic-ai/sdk', 'openai') * @returns true if wrapping should be skipped @@ -47,18 +66,16 @@ export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void { * ``` */ export function _INTERNAL_shouldSkipAiProviderWrapping(module: string): boolean { - return SKIPPED_AI_PROVIDERS.has(module); + return !!getSkips(getIsolationScope())?.has(module) || !!getSkips(getDefaultIsolationScope())?.has(module); } /** - * Clear all AI provider skip registrations. - * - * This is automatically called at the start of Sentry.init() to ensure a clean state - * between different client initializations. + * Clear the AI provider skip registrations of the current and the default isolation scope. * * @internal */ export function _INTERNAL_clearAiProviderSkips(): void { - SKIPPED_AI_PROVIDERS.clear(); + SKIPPED_AI_PROVIDERS.delete(getIsolationScope()); + SKIPPED_AI_PROVIDERS.delete(getDefaultIsolationScope()); DEBUG_BUILD && debug.log('Cleared AI provider skip registrations'); } diff --git a/packages/core/test/lib/utils/ai/providerSkip.test.ts b/packages/core/test/lib/utils/ai/providerSkip.test.ts index de39778a2b5e..cabe0a7a013b 100644 --- a/packages/core/test/lib/utils/ai/providerSkip.test.ts +++ b/packages/core/test/lib/utils/ai/providerSkip.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Scope } from '../../../../src/index'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_shouldSkipAiProviderWrapping, _INTERNAL_skipAiProviderWrapping, + getAsyncContextStrategy, + getDefaultIsolationScope, + getMainCarrier, + setAsyncContextStrategy, } from '../../../../src/index'; const OPENAI_INTEGRATION_NAME = 'OpenAI'; @@ -49,6 +54,80 @@ describe('AI Provider Skip', () => { }); }); + describe('isolation scope binding', () => { + // The stack strategy never forks the isolation scope, so the tests install one that does, + // the way the Node and Cloudflare strategies fork one per invocation. + let isolationScope: Scope; + + function withInvocation(callback: () => T): T { + const previous = isolationScope; + isolationScope = previous.clone(); + try { + return callback(); + } finally { + isolationScope = previous; + } + } + + beforeEach(() => { + isolationScope = getDefaultIsolationScope(); + setAsyncContextStrategy({ + ...getAsyncContextStrategy(getMainCarrier()), + getIsolationScope: () => isolationScope, + }); + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + _INTERNAL_clearAiProviderSkips(); + }); + + it('binds a skip registered inside an invocation to that invocation', () => { + withInvocation(() => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + withInvocation(() => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + }); + }); + + it('applies a skip registered outside any invocation inside every invocation', () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + withInvocation(() => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); + }); + }); + + it('does not let a skip from one invocation leak into a nested one', () => { + withInvocation(() => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + withInvocation(() => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + }); + + it('clears only the current and the default isolation scope', () => { + withInvocation(() => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + withInvocation(() => { + _INTERNAL_clearAiProviderSkips(); + }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + }); + }); + describe('_INTERNAL_clearAiProviderSkips', () => { it('clears all skip registrations', () => { _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]); diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 0f35938114bc..d933bb8e9480 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -3,7 +3,6 @@ import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; import type { ServerRuntimeClientOptions } from '@sentry/core'; import { - _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, _INTERNAL_setDeferSegmentSpanCapture, applySdkMetadata, @@ -178,13 +177,4 @@ export class NodeClient extends ServerRuntimeClient { process.on('beforeExit', this._clientReportOnExitFlushListener); } } - - /** @inheritDoc */ - protected _setupIntegrations(): void { - // Clear AI provider skip registrations before setting up integrations - // This ensures a clean state between different client initializations - // (e.g., when LangChain skips OpenAI in one client, but a subsequent client uses OpenAI standalone) - _INTERNAL_clearAiProviderSkips(); - super._setupIntegrations(); - } }