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 07cd93d331b7..13da3ec85a47 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -313,49 +313,26 @@ describe('LangChain integration', () => { 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { - test('demonstrates timing issue with duplicate spans (ESM only)', async () => { + test('suppresses provider spans inside LangChain calls but keeps direct calls', async () => { await createRunner() .ignore('event') .expect({ transaction: event => { - // This test highlights the limitation: if a user creates an Anthropic client - // before importing LangChain, that client will still be instrumented and - // could cause duplicate spans when used alongside LangChain. - const spans = event.spans || []; - // First call: Direct Anthropic call made BEFORE LangChain import - // This should have Anthropic instrumentation (origin: 'auto.ai.anthropic') - const firstAnthropicSpan = spans.find( + const anthropicSpans = spans.filter( span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', ); - - // Second call: LangChain call - // This should have LangChain instrumentation (origin: 'auto.ai.langchain') - const langchainSpan = spans.find( + const langchainSpans = spans.filter( span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.langchain', ); - // Third call: Direct Anthropic call made AFTER LangChain import - // This should NOT have Anthropic instrumentation (skip works correctly) - // Count how many Anthropic spans we have - should be exactly 1 - const anthropicSpans = spans.filter( - span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', - ); - - // Verify the edge case limitation: - // - First Anthropic client (created before LangChain) IS instrumented - expect(firstAnthropicSpan).toBeDefined(); - expect(firstAnthropicSpan?.origin).toBe('auto.ai.anthropic'); - - // - LangChain call IS instrumented by LangChain - expect(langchainSpan).toBeDefined(); - expect(langchainSpan?.origin).toBe('auto.ai.langchain'); + // Both direct Anthropic calls (before and after LangChain import) should produce spans + // Context-scoped suppression only suppresses spans inside LangChain calls, not globally + expect(anthropicSpans).toHaveLength(2); - // - Second Anthropic client (created after LangChain) is NOT instrumented - // This demonstrates that the skip mechanism works for NEW clients - // We should only have ONE Anthropic span (the first one), not two - expect(anthropicSpans).toHaveLength(1); + // LangChain call should produce exactly one LangChain span + expect(langchainSpans).toHaveLength(1); }, }) .start() 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 032e33c75dfd..5e3fe1621efc 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 @@ -347,49 +347,26 @@ conditionalTest({ min: 20 })('LangChain integration (v1)', () => { 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { - test('demonstrates timing issue with duplicate spans (ESM only)', async () => { + test('suppresses provider spans inside LangChain calls but keeps direct calls', async () => { await createRunner() .ignore('event') .expect({ transaction: event => { - // This test highlights the limitation: if a user creates an Anthropic client - // before importing LangChain, that client will still be instrumented and - // could cause duplicate spans when used alongside LangChain. - const spans = event.spans || []; - // First call: Direct Anthropic call made BEFORE LangChain import - // This should have Anthropic instrumentation (origin: 'auto.ai.anthropic') - const firstAnthropicSpan = spans.find( + const anthropicSpans = spans.filter( span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', ); - - // Second call: LangChain call - // This should have LangChain instrumentation (origin: 'auto.ai.langchain') - const langchainSpan = spans.find( + const langchainSpans = spans.filter( span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.langchain', ); - // Third call: Direct Anthropic call made AFTER LangChain import - // This should NOT have Anthropic instrumentation (skip works correctly) - // Count how many Anthropic spans we have - should be exactly 1 - const anthropicSpans = spans.filter( - span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic', - ); - - // Verify the edge case limitation: - // - First Anthropic client (created before LangChain) IS instrumented - expect(firstAnthropicSpan).toBeDefined(); - expect(firstAnthropicSpan?.origin).toBe('auto.ai.anthropic'); - - // - LangChain call IS instrumented by LangChain - expect(langchainSpan).toBeDefined(); - expect(langchainSpan?.origin).toBe('auto.ai.langchain'); + // Both direct Anthropic calls (before and after LangChain import) should produce spans + // Context-scoped suppression only suppresses spans inside LangChain calls, not globally + expect(anthropicSpans).toHaveLength(2); - // - Second Anthropic client (created after LangChain) is NOT instrumented - // This demonstrates that the skip mechanism works for NEW clients - // We should only have ONE Anthropic span (the first one), not two - expect(anthropicSpans).toHaveLength(1); + // LangChain call should produce exactly one LangChain span + expect(langchainSpans).toHaveLength(1); }, }) .start() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..0bf450d6dc82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,10 +58,9 @@ export { makeOfflineTransport } from './transports/offline'; export { makeMultiplexedTransport, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from './transports/multiplexed'; export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration'; export { - _INTERNAL_skipAiProviderWrapping, - _INTERNAL_shouldSkipAiProviderWrapping, - _INTERNAL_clearAiProviderSkips, -} from './utils/ai/providerSkip'; + _INTERNAL_isAiProviderSpanSuppressed, + _INTERNAL_withSuppressedAiProviderSpans, +} from './tracing/ai/suppression'; export { envToBool } from './utils/envToBool'; export { applyScopeDataToEvent, mergeScopeData, getCombinedScopeData } from './utils/scopeData'; export { prepareEvent } from './utils/prepareEvent'; 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 63ff1be0e52f..477cb0de15ca 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -2,6 +2,7 @@ import { getClient } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../../tracing/ai/suppression'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; @@ -256,6 +257,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 requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 7781b67d6db0..f805db2db60f 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -2,6 +2,7 @@ import { getClient } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../../tracing/ai/suppression'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; @@ -260,6 +261,10 @@ function instrumentMethod( return new Proxy(originalMethod, { apply(target, _, args: T): R | Promise { + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return Reflect.apply(target, _, args); + } + const params = args[0] as Record | undefined; const requestAttributes = extractRequestAttributes(methodPath, params, context); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index cfbdc5cfb4b1..4711c9a6dae6 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -19,6 +19,7 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, OPENAI_OPERATIONS, } from '../ai/gen-ai-attributes'; +import { _INTERNAL_isAiProviderSpanSuppressed } from '../ai/suppression'; import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; import { instrumentStream } from './streaming'; import type { @@ -254,6 +255,10 @@ function instrumentMethod( options: OpenAiOptions, ): (...args: T) => Promise { return function instrumentedMethod(...args: T): Promise { + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return originalMethod.apply(context, args) as Promise; + } + const requestAttributes = extractRequestAttributes(args, methodPath); const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; const operationName = getOperationName(methodPath); diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 919c06eb12d6..c19204b35d8b 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; 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 80a233aa3954..ee0e0b8585c2 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -4,14 +4,7 @@ import { trace } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; -import { - _INTERNAL_clearAiProviderSkips, - _INTERNAL_flushLogsBuffer, - applySdkMetadata, - debug, - SDK_VERSION, - ServerRuntimeClient, -} from '@sentry/core'; +import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; import { type AsyncLocalStorageLookup, getTraceContextForScope } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; @@ -157,10 +150,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(); } diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts index 4fc96aa5ea92..9ff37328b791 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -5,13 +5,7 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { AnthropicAiClient, AnthropicAiOptions } from '@sentry/core'; -import { - _INTERNAL_shouldSkipAiProviderWrapping, - ANTHROPIC_AI_INTEGRATION_NAME, - getClient, - instrumentAnthropicAiClient, - SDK_VERSION, -} from '@sentry/core'; +import { getClient, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; @@ -54,11 +48,6 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase=0.10.0 <2']; @@ -71,11 +64,6 @@ export class SentryGoogleGenAiInstrumentation extends InstrumentationBase { + return Reflect.apply(target, thisArg, args); + }); }, }) as (...args: unknown[]) => unknown; } @@ -170,14 +170,6 @@ export class SentryLangChainInstrumentation extends InstrumentationBase=4.0.0 <7']; @@ -68,11 +62,6 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase