From c96914f5744e9be6e632272aa77faef73adb5d66 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 17 Jul 2026 14:49:47 +0200 Subject: [PATCH 1/3] fix(node): Use context-scoped suppression for LangChain + AI provider span deduplication Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scenario-openai-before-langchain.mjs | 12 ++-- .../suites/tracing/langchain/test.ts | 30 ++++---- .../v1/scenario-openai-before-langchain.mjs | 12 ++-- .../suites/tracing/langchain/v1/test.ts | 28 ++++---- packages/core/src/shared-exports.ts | 7 +- packages/core/src/tracing/ai/suppression.ts | 27 +++++++ .../core/src/tracing/anthropic-ai/index.ts | 5 ++ .../core/src/tracing/google-genai/index.ts | 5 ++ .../core/src/tracing/langchain/embeddings.ts | 18 +++-- packages/core/src/tracing/openai/index.ts | 5 ++ packages/core/src/utils/ai/providerSkip.ts | 64 ----------------- .../test/lib/utils/ai/providerSkip.test.ts | 71 ------------------- packages/node-core/src/sdk/client.ts | 10 --- .../tracing/anthropic-ai/instrumentation.ts | 12 +--- .../tracing/google-genai/instrumentation.ts | 13 +--- .../integrations/tracing/langchain/index.ts | 6 +- .../tracing/langchain/instrumentation.ts | 19 ++--- .../tracing/openai/instrumentation.ts | 12 +--- .../integrations/tracing-channel/anthropic.ts | 8 +-- .../tracing-channel/google-genai.ts | 8 +-- .../integrations/tracing-channel/openai.ts | 8 +-- 21 files changed, 118 insertions(+), 262 deletions(-) create mode 100644 packages/core/src/tracing/ai/suppression.ts delete mode 100644 packages/core/src/utils/ai/providerSkip.ts delete mode 100644 packages/core/test/lib/utils/ai/providerSkip.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs index f194acb1672b..931281a01290 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs @@ -38,9 +38,7 @@ async function run() { const baseURL = `http://localhost:${server.address().port}`; await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // EDGE CASE: Import and instantiate Anthropic client BEFORE LangChain is imported - // This simulates the timing issue where a user creates an Anthropic client in one file - // before importing LangChain in another file + // Direct Anthropic call made BEFORE LangChain is imported/used const { default: Anthropic } = await import('@anthropic-ai/sdk'); const anthropicClient = new Anthropic({ apiKey: 'mock-api-key', @@ -55,8 +53,8 @@ async function run() { max_tokens: 100, }); - // NOW import LangChain - at this point it will mark Anthropic to be skipped - // But the client created above is already instrumented + // Import and use LangChain - its own instrumentation records the span and suppresses the nested + // Anthropic SDK span only for the duration of the call. const { ChatAnthropic } = await import('@langchain/anthropic'); // Create a LangChain model - this uses Anthropic under the hood @@ -73,8 +71,8 @@ async function run() { // Use LangChain - this will be instrumented by LangChain integration await langchainModel.invoke('LangChain Anthropic call'); - // Create ANOTHER Anthropic client after LangChain was imported - // This one should NOT be instrumented (skip mechanism works correctly) + // Direct Anthropic call made AFTER LangChain was used - still instrumented, since suppression + // is scoped to the LangChain call and does not leak to direct provider calls. const anthropicClient2 = new Anthropic({ apiKey: 'mock-api-key', baseURL, 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 ed342ce9d1a2..65e13fa60c6f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -248,31 +248,29 @@ describe('LangChain integration', () => { ); createEsmTests(__dirname, 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { - test('demonstrates timing issue with duplicate spans', async () => { + test('suppresses provider spans inside LangChain calls but keeps direct calls', async () => { await createRunner() .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(2); - const anthropicSpan = container.items.find( - span => - span.attributes['sentry.origin'].value === - (isOrchestrionEnabled() ? 'auto.ai.orchestrion.anthropic' : 'auto.ai.anthropic'), + expect(container.items).toHaveLength(3); + + const anthropicOrigin = isOrchestrionEnabled() ? 'auto.ai.orchestrion.anthropic' : 'auto.ai.anthropic'; + + // Both direct Anthropic calls (before and after the LangChain import) are instrumented. + // Suppression is scoped to the LangChain call, so direct calls keep their spans. + const anthropicSpans = container.items.filter( + span => span.attributes['sentry.origin'].value === anthropicOrigin, ); - expect(anthropicSpan).toBeDefined(); - expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); + expect(anthropicSpans).toHaveLength(2); - // LangChain call is instrumented by LangChain. - const langchainSpan = container.items.find( + // The LangChain call produces exactly one LangChain span; the nested Anthropic call is suppressed. + const langchainSpans = container.items.filter( span => span.attributes['sentry.origin'].value === 'auto.ai.langchain', ); - expect(langchainSpan).toBeDefined(); - expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - - // Third call (not present): Direct Anthropic call made AFTER LangChain import - // is NOT instrumented, which demonstrates the skip mechanism works for NEW - // clients. We should only have ONE Anthropic span (the first one), not two. + expect(langchainSpans).toHaveLength(1); + expect(langchainSpans[0]!.name).toBe('chat claude-3-5-sonnet-20241022'); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-openai-before-langchain.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-openai-before-langchain.mjs index f194acb1672b..931281a01290 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-openai-before-langchain.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-openai-before-langchain.mjs @@ -38,9 +38,7 @@ async function run() { const baseURL = `http://localhost:${server.address().port}`; await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // EDGE CASE: Import and instantiate Anthropic client BEFORE LangChain is imported - // This simulates the timing issue where a user creates an Anthropic client in one file - // before importing LangChain in another file + // Direct Anthropic call made BEFORE LangChain is imported/used const { default: Anthropic } = await import('@anthropic-ai/sdk'); const anthropicClient = new Anthropic({ apiKey: 'mock-api-key', @@ -55,8 +53,8 @@ async function run() { max_tokens: 100, }); - // NOW import LangChain - at this point it will mark Anthropic to be skipped - // But the client created above is already instrumented + // Import and use LangChain - its own instrumentation records the span and suppresses the nested + // Anthropic SDK span only for the duration of the call. const { ChatAnthropic } = await import('@langchain/anthropic'); // Create a LangChain model - this uses Anthropic under the hood @@ -73,8 +71,8 @@ async function run() { // Use LangChain - this will be instrumented by LangChain integration await langchainModel.invoke('LangChain Anthropic call'); - // Create ANOTHER Anthropic client after LangChain was imported - // This one should NOT be instrumented (skip mechanism works correctly) + // Direct Anthropic call made AFTER LangChain was used - still instrumented, since suppression + // is scoped to the LangChain call and does not leak to direct provider calls. const anthropicClient2 = new Anthropic({ apiKey: 'mock-api-key', baseURL, 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 b555e48229e4..9d7d2212e6f6 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 @@ -279,29 +279,29 @@ conditionalTest({ min: 20 })('LangChain integration (v1)', () => { 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { - test('demonstrates timing issue with duplicate spans', async () => { + test('suppresses provider spans inside LangChain calls but keeps direct calls', async () => { await createRunner() .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(2); - const anthropicSpan = container.items.find( - span => - span.attributes['sentry.origin'].value === - (isOrchestrionEnabled() ? 'auto.ai.orchestrion.anthropic' : 'auto.ai.anthropic'), + expect(container.items).toHaveLength(3); + + const anthropicOrigin = isOrchestrionEnabled() ? 'auto.ai.orchestrion.anthropic' : 'auto.ai.anthropic'; + + // Both direct Anthropic calls (before and after the LangChain import) are instrumented. + // Suppression is scoped to the LangChain call, so direct calls keep their spans. + const anthropicSpans = container.items.filter( + span => span.attributes['sentry.origin'].value === anthropicOrigin, ); - expect(anthropicSpan).toBeDefined(); - expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); + expect(anthropicSpans).toHaveLength(2); - const langchainSpan = container.items.find( + // The LangChain call produces exactly one LangChain span; the nested Anthropic call is suppressed. + const langchainSpans = container.items.filter( span => span.attributes['sentry.origin'].value === 'auto.ai.langchain', ); - expect(langchainSpan).toBeDefined(); - expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - - // Third call (not present): Direct Anthropic call made AFTER LangChain import - // is NOT instrumented, demonstrating the skip mechanism works for NEW clients. + expect(langchainSpans).toHaveLength(1); + expect(langchainSpans[0]!.name).toBe('chat claude-3-5-sonnet-20241022'); }, }) .start() diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 271cc7bbb004..4073a85d7ef7 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -73,10 +73,9 @@ export { installedIntegrations, } from './integration'; export { - _INTERNAL_skipAiProviderWrapping, - _INTERNAL_shouldSkipAiProviderWrapping, - _INTERNAL_clearAiProviderSkips, -} from './utils/ai/providerSkip'; + _INTERNAL_isAiProviderSpanSuppressed, + _INTERNAL_withSuppressedAiProviderSpans, +} from './tracing/ai/suppression'; export { filterKeyValueData as _INTERNAL_filterKeyValueData } from './utils/data-collection/filterKeyValueData'; export { filterCookies as _INTERNAL_filterCookies } from './utils/data-collection/filterCookies'; export { filterQueryParams as _INTERNAL_filterQueryParams } from './utils/data-collection/filterQueryParams'; diff --git a/packages/core/src/tracing/ai/suppression.ts b/packages/core/src/tracing/ai/suppression.ts new file mode 100644 index 000000000000..1e5f66e14ce3 --- /dev/null +++ b/packages/core/src/tracing/ai/suppression.ts @@ -0,0 +1,27 @@ +import { getCurrentScope, withScope } from '../../currentScopes'; +import type { Scope } from '../../scope'; + +const SUPPRESS_AI_PROVIDER_SPANS_KEY = '__SENTRY_SUPPRESS_AI_PROVIDER_SPANS__'; + +/** + * Check if AI provider spans should be suppressed in the current scope. + * + * @internal + */ +export function _INTERNAL_isAiProviderSpanSuppressed(): boolean { + return getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_AI_PROVIDER_SPANS_KEY] === true; +} + +/** + * Execute a callback with AI provider spans suppressed in the current scope. + * This is used by higher-level integrations (like LangChain) to prevent + * duplicate spans from underlying AI provider instrumentations. + * + * @internal + */ +export function _INTERNAL_withSuppressedAiProviderSpans(callback: () => T): T { + return withScope((scope: Scope) => { + scope.setSDKProcessingMetadata({ [SUPPRESS_AI_PROVIDER_SPANS_KEY]: true }); + return callback(); + }); +} diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 4238bcb870db..227d3292fbf0 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -20,6 +20,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../ai/suppression'; import type { InstrumentedMethodEntry } from '../ai/utils'; import { buildMethodPath, @@ -262,6 +263,10 @@ function instrumentMethod( ): (...args: T) => R | Promise { return new Proxy(originalMethod, { apply(target, thisArg, args: T): R | Promise { + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return Reflect.apply(target, thisArg, args); + } + const operationName = instrumentedMethod.operation || 'unknown'; const requestAttributes = extractRequestAttributes(args, methodPath, operationName); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 68e4d414586a..400d2b42b332 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -27,6 +27,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../ai/suppression'; import type { InstrumentedMethodEntry } from '../ai/utils'; import { stringify } from '../../utils/string'; import { @@ -281,6 +282,10 @@ function instrumentMethod( return new Proxy(originalMethod, { apply(target, _, args: T): R | Promise { + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return Reflect.apply(target, _, args); + } + const operationName = instrumentedMethod.operation || 'unknown'; const params = args[0] as Record | undefined; const requestAttributes = extractRequestAttributes(operationName, params, context); diff --git a/packages/core/src/tracing/langchain/embeddings.ts b/packages/core/src/tracing/langchain/embeddings.ts index 64cda4e413c3..b92f17e474f2 100644 --- a/packages/core/src/tracing/langchain/embeddings.ts +++ b/packages/core/src/tracing/langchain/embeddings.ts @@ -11,6 +11,7 @@ import { GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { _INTERNAL_withSuppressedAiProviderSpans } from '../ai/suppression'; import { resolveAIRecordingOptions } from '../ai/utils'; import { LANGCHAIN_ORIGIN } from './constants'; import type { LangChainOptions } from './types'; @@ -85,12 +86,17 @@ export function instrumentEmbeddingMethod( attributes: attributes as Record, }, () => { - return Reflect.apply(target, thisArg, args).then(undefined, error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.langchain' }, - }); - throw error; - }); + // `embedQuery`/`embedDocuments` call the underlying provider SDK (e.g. openai) internally, so + // suppress that SDK's own instrumentation for this call to avoid a duplicate span. + return _INTERNAL_withSuppressedAiProviderSpans(() => Reflect.apply(target, thisArg, args)).then( + undefined, + error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.langchain' }, + }); + throw error; + }, + ); }, ); }, diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 821e9c68e0ff..b9a6a332cff4 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -15,6 +15,7 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../ai/suppression'; import type { InstrumentedMethodEntry } from '../ai/utils'; import { stringify } from '../../utils/string'; import { @@ -151,6 +152,10 @@ function instrumentMethod( options: OpenAiOptions, ): (...args: T) => Promise { return function instrumentedCall(...args: T): Promise { + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return originalMethod.apply(context, args); + } + const operationName = instrumentedMethod.operation || 'unknown'; const requestAttributes = extractRequestAttributes(args, operationName); const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; diff --git a/packages/core/src/utils/ai/providerSkip.ts b/packages/core/src/utils/ai/providerSkip.ts deleted file mode 100644 index 0b7ca2a5c3bc..000000000000 --- a/packages/core/src/utils/ai/providerSkip.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DEBUG_BUILD } from '../../debug-build'; -import { debug } from '../debug-logger'; - -/** - * Registry tracking which AI provider modules should skip instrumentation wrapping. - * - * This prevents duplicate spans when a higher-level integration (like LangChain) - * already instruments AI providers at a higher abstraction level. - */ -const SKIPPED_AI_PROVIDERS = new Set(); - -/** - * Mark AI provider modules to skip instrumentation wrapping. - * - * This prevents duplicate spans when a higher-level integration (like LangChain) - * already instruments AI providers at a higher abstraction level. - * - * @internal - * @param modules - Array of npm module names to skip (e.g., '@anthropic-ai/sdk', 'openai') - * - * @example - * ```typescript - * // In LangChain integration - * _INTERNAL_skipAiProviderWrapping(['@anthropic-ai/sdk', 'openai', '@google/generative-ai']); - * ``` - */ -export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void { - modules.forEach(module => { - SKIPPED_AI_PROVIDERS.add(module); - DEBUG_BUILD && debug.log(`AI provider "${module}" wrapping will be skipped`); - }); -} - -/** - * Check if an AI provider module should skip instrumentation wrapping. - * - * @internal - * @param module - The npm module name (e.g., '@anthropic-ai/sdk', 'openai') - * @returns true if wrapping should be skipped - * - * @example - * ```typescript - * // In AI provider instrumentation - * if (_INTERNAL_shouldSkipAiProviderWrapping('@anthropic-ai/sdk')) { - * return Reflect.construct(Original, args); // Don't instrument - * } - * ``` - */ -export function _INTERNAL_shouldSkipAiProviderWrapping(module: string): boolean { - return SKIPPED_AI_PROVIDERS.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. - * - * @internal - */ -export function _INTERNAL_clearAiProviderSkips(): void { - SKIPPED_AI_PROVIDERS.clear(); - 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 deleted file mode 100644 index 99ec76c970d6..000000000000 --- a/packages/core/test/lib/utils/ai/providerSkip.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { - _INTERNAL_clearAiProviderSkips, - _INTERNAL_shouldSkipAiProviderWrapping, - _INTERNAL_skipAiProviderWrapping, - ANTHROPIC_AI_INTEGRATION_NAME, - GOOGLE_GENAI_INTEGRATION_NAME, - OPENAI_INTEGRATION_NAME, -} from '../../../../src/index'; - -describe('AI Provider Skip', () => { - beforeEach(() => { - _INTERNAL_clearAiProviderSkips(); - }); - - describe('_INTERNAL_skipAiProviderWrapping', () => { - it('marks a single provider to be skipped', () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); - }); - - it('marks multiple providers to be skipped', () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false); - }); - - it('is idempotent - can mark same provider multiple times', () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - }); - }); - - describe('_INTERNAL_shouldSkipAiProviderWrapping', () => { - it('returns false for unmarked providers', () => { - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); - expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false); - }); - - it('returns true after marking provider to be skipped', () => { - _INTERNAL_skipAiProviderWrapping([ANTHROPIC_AI_INTEGRATION_NAME]); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - }); - }); - - describe('_INTERNAL_clearAiProviderSkips', () => { - it('clears all skip registrations', () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - - _INTERNAL_clearAiProviderSkips(); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false); - }); - - it('can be called multiple times safely', () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - _INTERNAL_clearAiProviderSkips(); - _INTERNAL_clearAiProviderSkips(); - _INTERNAL_clearAiProviderSkips(); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - }); - }); -}); diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index fa9dfeae1e3e..73eb534823d8 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -4,7 +4,6 @@ import { trace } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; import { - _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, _INTERNAL_setDeferSegmentSpanCapture, applySdkMetadata, @@ -169,15 +168,6 @@ export class NodeClient extends ServerRuntimeClient { } } - /** @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(); - } - /** Custom implementation for OTEL, so we can handle scope-span linking. */ protected _getTraceInfoFromScope( scope: Scope | undefined, diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts index b01b34f2f8ad..c0cf607a68ae 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -5,12 +5,7 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { AnthropicAiClient, AnthropicAiOptions } from '@sentry/core'; -import { - _INTERNAL_shouldSkipAiProviderWrapping, - ANTHROPIC_AI_INTEGRATION_NAME, - instrumentAnthropicAiClient, - SDK_VERSION, -} from '@sentry/core'; +import { instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; @@ -53,11 +48,6 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase=0.10.0 <2']; @@ -67,11 +61,6 @@ export class SentryGoogleGenAiInstrumentation extends InstrumentationBase { * When configured, this integration automatically instruments LangChain runnable instances * to capture telemetry data by injecting Sentry callback handlers into all LangChain calls. * - * **Important:** This integration automatically skips wrapping the OpenAI, Anthropic, and Google GenAI - * providers to prevent duplicate spans when using LangChain with these AI providers. - * LangChain handles the instrumentation for all underlying AI providers. + * **Important:** While a LangChain call is executing, this integration suppresses the OpenAI, Anthropic, + * and Google GenAI provider instrumentations for that call to prevent duplicate spans. The suppression is + * scoped to the LangChain call, so direct provider SDK calls outside of LangChain keep their own spans. * * @example * ```javascript diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts index 4ddbaee00c60..37e503cadc5e 100644 --- a/packages/node/src/integrations/tracing/langchain/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -8,12 +8,9 @@ import { InstrumentationNodeModuleFile } from '../InstrumentationNodeModuleFile' import type { LangChainOptions } from '@sentry/core'; import { _INTERNAL_mergeLangChainCallbackHandler, - _INTERNAL_skipAiProviderWrapping, - ANTHROPIC_AI_INTEGRATION_NAME, + _INTERNAL_withSuppressedAiProviderSpans, createLangChainCallbackHandler, - GOOGLE_GENAI_INTEGRATION_NAME, instrumentLangChainEmbeddings, - OPENAI_INTEGRATION_NAME, SDK_VERSION, } from '@sentry/core'; @@ -57,8 +54,10 @@ function wrapRunnableMethod( // Inject our callback handler into options.callbacks (request time callbacks) options.callbacks = _INTERNAL_mergeLangChainCallbackHandler(options.callbacks, sentryHandler); - // Call original method with augmented options - return Reflect.apply(target, thisArg, args); + // LangChain records the span itself via the injected callback handler, so suppress the + // underlying provider SDK's own instrumentation for the duration of this call to avoid + // double spans. Scope-based, so direct provider calls outside LangChain keep their spans. + return _INTERNAL_withSuppressedAiProviderSpans(() => Reflect.apply(target, thisArg, args)); }, }) as (...args: unknown[]) => unknown; } @@ -141,14 +140,6 @@ export class SentryLangChainInstrumentation extends InstrumentationBase=4.0.0 <7']; @@ -67,11 +62,6 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase { * Returning `undefined` opts the payload out so no span is opened. */ function createGenAiSpan(data: OpenAiChatChannelContext, operation: string, options: OpenAiOptions): Span | undefined { - // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and marks this - // provider as skipped; skip here to avoid double spans. - if (_INTERNAL_shouldSkipAiProviderWrapping(INTEGRATION_NAME)) { + // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and suppresses + // provider spans on the current scope; skip here to avoid double spans. + if (_INTERNAL_isAiProviderSpanSuppressed()) { return undefined; } From 55118d9eb371b518226d57ced7cb89bbcd8889b0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 17 Jul 2026 15:53:23 +0200 Subject: [PATCH 2/3] docs(ai): Document provider span suppression in skill + add Bugbot rule Adapts the add-ai-integration skill to require lowest-level provider integrations to check _INTERNAL_isAiProviderSpanSuppressed(), and adds a Bugbot rule to lint for missing suppression checks / global suppression. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/add-ai-integration/SKILL.md | 18 +++++++++++++++--- .cursor/BUGBOT.md | 3 +++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.agents/skills/add-ai-integration/SKILL.md b/.agents/skills/add-ai-integration/SKILL.md index 8323aa86fd56..a2cc89e9dc82 100644 --- a/.agents/skills/add-ai-integration/SKILL.md +++ b/.agents/skills/add-ai-integration/SKILL.md @@ -64,8 +64,8 @@ Reference: `packages/node/src/integrations/tracing/vercelai/` **Use when:** SDK has no native OTel support (OpenAI, Anthropic, Google GenAI) -1. **Core:** Create `instrument{Provider}Client()` in `packages/core/src/tracing/{provider}/index.ts` — Proxy to wrap client methods, create spans manually -2. **Node.js `instrumentation.ts`:** Patch module exports, wrap client constructor. Check `_INTERNAL_shouldSkipAiProviderWrapping()` for LangChain compatibility. +1. **Core:** Create `instrument{Provider}Client()` in `packages/core/src/tracing/{provider}/index.ts` — Proxy to wrap client methods, create spans manually. As the lowest-level integration, bail out of span creation when `_INTERNAL_isAiProviderSpanSuppressed()` returns `true` (see Provider Span Suppression). +2. **Node.js `instrumentation.ts`:** Patch module exports, wrap client constructor. 3. **Node.js `index.ts`:** Export integration function using `generateInstrumentOnce()` helper Reference: `packages/node/src/integrations/tracing/openai/` @@ -75,10 +75,21 @@ Reference: `packages/node/src/integrations/tracing/openai/` **Use when:** SDK provides lifecycle hooks (LangChain, LangGraph) 1. **Core:** Create `create{Provider}CallbackHandler()` — implement SDK's callback interface, create spans in callbacks -2. **Node.js `instrumentation.ts`:** Auto-inject callbacks by patching runnable methods. Disable underlying AI provider wrapping. +2. **Node.js `instrumentation.ts`:** Auto-inject callbacks by patching runnable methods. Wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans()` so the lower-level provider SDK doesn't create duplicate spans (see Provider Span Suppression). Reference: `packages/node/src/integrations/tracing/langchain/` +## Provider Span Suppression + +Higher-level integrations (LangChain, LangGraph) drive lower-level provider SDKs (OpenAI, Anthropic, Google GenAI) under the hood. Without coordination, a single LangChain call produces duplicate `gen_ai.*` spans — one from the callback handler and one from the provider instrumentation. + +Coordinate via the scope-based helpers in `packages/core/src/tracing/ai/suppression.ts`: + +- **Higher-level integration:** wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans(() => ...)`. This sets a flag on a forked scope for the duration of the call only. +- **Lowest-level integration (the one that actually talks to the provider SDK):** at the very top of span creation (e.g. the `instrumentMethod` proxy `apply`), bail out and call through to the original method when `_INTERNAL_isAiProviderSpanSuppressed()` is `true`. + +The suppression is scoped, not global: direct provider SDK calls made outside a higher-level call keep their spans. Any new lowest-level provider integration **must** implement this check, or it will double-instrument when used through LangChain/LangGraph. This applies to embeddings paths too, since `embedQuery`/`embedDocuments` call the provider SDK internally. + ## Auto-Instrumentation (Node.js) **Mandatory** for Node.js AI integrations. OTel only patches when the package is imported (zero cost if unused). @@ -99,6 +110,7 @@ Reference: `packages/node/src/integrations/tracing/langchain/` 2. Set `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'auto.ai.{provider}'` (alphanumerics, `_`, `.` only) 3. Truncate large data with helper functions from `utils.ts` 4. `gen_ai.invoke_agent` for parent ops, `gen_ai.chat` for child ops +5. Lowest-level provider integrations must check `_INTERNAL_isAiProviderSpanSuppressed()` before creating spans (see Provider Span Suppression) ## Checklist diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 1300f2cc8590..83cf76095df1 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -45,6 +45,9 @@ Unless explicitly noted (e.g. in the `Testing Conventions` section), only flag t - Only consider calling `captureException` if the instrumentation prevents errors from bubbling up (e.g. by swallowing them in a `try/catch` or an error event listener). Doing so is generally discouraged — prefer to let the error propagate instead. - Flag any instrumentation that swallows errors without calling `captureException`, and any instrumentation that calls `captureException` even though the error would still bubble up to the user (which causes double-reporting). - When calling `generateInstrumentationOnce`, the passed in name MUST match the name of the integration that uses it. If there are multiple instrumentations, they need to follow the pattern `${INSTRUMENTATION_NAME}.some-suffix`. +- For AI provider instrumentations, ensure duplicate spans are avoided when a higher-level integration (e.g. LangChain, LangGraph) drives a lower-level provider SDK (OpenAI, Anthropic, Google GenAI): + - The lowest-level integration that talks to the provider SDK MUST bail out of span creation when `_INTERNAL_isAiProviderSpanSuppressed()` returns `true`. Flag any AI provider `startSpan`/`instrumentMethod` path (including embeddings methods that call the provider SDK internally) that creates spans without this check. + - Higher-level integrations MUST wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans(() => ...)` rather than globally disabling provider instrumentation. Flag any process-global suppression mechanism, since it drops spans for direct provider calls unrelated to the higher-level integration. - Flag any unguarded `debug.log` / `debug.warn` / `debug.error` call in SDK source. The convention is the short-circuit form `DEBUG_BUILD && debug.log(...)` (not `if (DEBUG_BUILD) { ... }` wrapping). Without the `DEBUG_BUILD` gate the message text ships in production bundles and bloats bundle size. - Flag direct `console.log` / `console.warn` / `console.error` / `console.info` / `console.debug` calls in SDK source. The accepted patterns are: - The SDK's `debug` logger (gated with `DEBUG_BUILD && debug.*`) for SDK-internal diagnostics. From f522c234aab1038d2619f459a5bb4732c120c781 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 17 Jul 2026 15:55:26 +0200 Subject: [PATCH 3/3] docs(ai): Trim provider span suppression skill/bugbot notes Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/add-ai-integration/SKILL.md | 10 +++------- .cursor/BUGBOT.md | 4 +--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.agents/skills/add-ai-integration/SKILL.md b/.agents/skills/add-ai-integration/SKILL.md index a2cc89e9dc82..7a31a6d26261 100644 --- a/.agents/skills/add-ai-integration/SKILL.md +++ b/.agents/skills/add-ai-integration/SKILL.md @@ -81,14 +81,10 @@ Reference: `packages/node/src/integrations/tracing/langchain/` ## Provider Span Suppression -Higher-level integrations (LangChain, LangGraph) drive lower-level provider SDKs (OpenAI, Anthropic, Google GenAI) under the hood. Without coordination, a single LangChain call produces duplicate `gen_ai.*` spans — one from the callback handler and one from the provider instrumentation. +When a higher-level integration (LangChain, LangGraph) drives a provider SDK, both would create spans for one call. Coordinate via the scope-based helpers in `packages/core/src/tracing/ai/suppression.ts`: -Coordinate via the scope-based helpers in `packages/core/src/tracing/ai/suppression.ts`: - -- **Higher-level integration:** wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans(() => ...)`. This sets a flag on a forked scope for the duration of the call only. -- **Lowest-level integration (the one that actually talks to the provider SDK):** at the very top of span creation (e.g. the `instrumentMethod` proxy `apply`), bail out and call through to the original method when `_INTERNAL_isAiProviderSpanSuppressed()` is `true`. - -The suppression is scoped, not global: direct provider SDK calls made outside a higher-level call keep their spans. Any new lowest-level provider integration **must** implement this check, or it will double-instrument when used through LangChain/LangGraph. This applies to embeddings paths too, since `embedQuery`/`embedDocuments` call the provider SDK internally. +- **Higher-level integration:** wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans(() => ...)`. +- **Lowest-level integration (talks to the provider SDK):** bail out of span creation when `_INTERNAL_isAiProviderSpanSuppressed()` is `true`, including embeddings paths that call the SDK internally. ## Auto-Instrumentation (Node.js) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 83cf76095df1..7f737091e9e1 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -45,9 +45,7 @@ Unless explicitly noted (e.g. in the `Testing Conventions` section), only flag t - Only consider calling `captureException` if the instrumentation prevents errors from bubbling up (e.g. by swallowing them in a `try/catch` or an error event listener). Doing so is generally discouraged — prefer to let the error propagate instead. - Flag any instrumentation that swallows errors without calling `captureException`, and any instrumentation that calls `captureException` even though the error would still bubble up to the user (which causes double-reporting). - When calling `generateInstrumentationOnce`, the passed in name MUST match the name of the integration that uses it. If there are multiple instrumentations, they need to follow the pattern `${INSTRUMENTATION_NAME}.some-suffix`. -- For AI provider instrumentations, ensure duplicate spans are avoided when a higher-level integration (e.g. LangChain, LangGraph) drives a lower-level provider SDK (OpenAI, Anthropic, Google GenAI): - - The lowest-level integration that talks to the provider SDK MUST bail out of span creation when `_INTERNAL_isAiProviderSpanSuppressed()` returns `true`. Flag any AI provider `startSpan`/`instrumentMethod` path (including embeddings methods that call the provider SDK internally) that creates spans without this check. - - Higher-level integrations MUST wrap the underlying call in `_INTERNAL_withSuppressedAiProviderSpans(() => ...)` rather than globally disabling provider instrumentation. Flag any process-global suppression mechanism, since it drops spans for direct provider calls unrelated to the higher-level integration. +- For AI provider instrumentations (to avoid duplicate spans when a higher-level integration like LangChain drives a provider SDK): flag any provider span-creation path (including embeddings methods that call the SDK internally) that does not bail out when `_INTERNAL_isAiProviderSpanSuppressed()` is `true`, and flag any process-global suppression mechanism (use `_INTERNAL_withSuppressedAiProviderSpans` instead, which is scoped). - Flag any unguarded `debug.log` / `debug.warn` / `debug.error` call in SDK source. The convention is the short-circuit form `DEBUG_BUILD && debug.log(...)` (not `if (DEBUG_BUILD) { ... }` wrapping). Without the `DEBUG_BUILD` gate the message text ships in production bundles and bloats bundle size. - Flag direct `console.log` / `console.warn` / `console.error` / `console.info` / `console.debug` calls in SDK source. The accepted patterns are: - The SDK's `debug` logger (gated with `DEBUG_BUILD && debug.*`) for SDK-internal diagnostics.