From c8d45cfc7beddc1952e43e2e895ed22db6211f67 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Jul 2026 10:38:26 +0200 Subject: [PATCH 1/2] fix(node): Use context-scoped suppression for LangChain + AI provider span deduplication Replaces the global AI-provider skip flag with context-scoped suppression for the OTel LangChain path: LangChain invoke/stream/batch (and embeddings) run inside `_INTERNAL_withSuppressedAiProviderSpans`, and the core provider instrumentations skip span creation when `_INTERNAL_isAiProviderSpanSuppressed()`. Direct provider SDK calls outside LangChain keep their spans, fixing globally-dropped provider spans. The orchestrion provider channels continue to use the global skip flag for now; a follow-up converts them to scoped suppression. Fixes #19687 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...> scenario-anthropic-before-langchain.mjs} | 12 +++---- .../suites/tracing/langchain/test.ts | 32 ++++++++--------- ...> scenario-anthropic-before-langchain.mjs} | 12 +++---- .../suites/tracing/langchain/v1/test.ts | 30 ++++++++-------- packages/core/src/shared-exports.ts | 4 +++ packages/core/src/tracing/ai/suppression.ts | 34 +++++++++++++++++++ .../core/src/tracing/anthropic-ai/index.ts | 8 +++++ .../core/src/tracing/google-genai/index.ts | 5 +++ .../core/src/tracing/langchain/embeddings.ts | 18 ++++++---- packages/core/src/tracing/openai/index.ts | 5 +++ .../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 +------ 15 files changed, 119 insertions(+), 103 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/langchain/{scenario-openai-before-langchain.mjs => scenario-anthropic-before-langchain.mjs} (81%) rename dev-packages/node-integration-tests/suites/tracing/langchain/v1/{scenario-openai-before-langchain.mjs => scenario-anthropic-before-langchain.mjs} (81%) create mode 100644 packages/core/src/tracing/ai/suppression.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-anthropic-before-langchain.mjs similarity index 81% rename from dev-packages/node-integration-tests/suites/tracing/langchain/scenario-openai-before-langchain.mjs rename to dev-packages/node-integration-tests/suites/tracing/langchain/scenario-anthropic-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-anthropic-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..423be256e0a2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -247,32 +247,30 @@ describe('LangChain integration', () => { }, ); - createEsmTests(__dirname, 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { - test('demonstrates timing issue with duplicate spans', async () => { + createEsmTests(__dirname, 'scenario-anthropic-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { + 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-anthropic-before-langchain.mjs similarity index 81% rename from dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-openai-before-langchain.mjs rename to dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-anthropic-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-anthropic-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..819b93766394 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 @@ -276,32 +276,32 @@ conditionalTest({ min: 20 })('LangChain integration (v1)', () => { createEsmTests( __dirname, - 'scenario-openai-before-langchain.mjs', + 'scenario-anthropic-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 983367c882f9..ea2b69611c74 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -72,6 +72,10 @@ export { extendIntegration, installedIntegrations, } from './integration'; +export { + _INTERNAL_isAiProviderSpanSuppressed, + _INTERNAL_withSuppressedAiProviderSpans, +} from './tracing/ai/suppression'; export { _INTERNAL_skipAiProviderWrapping, _INTERNAL_shouldSkipAiProviderWrapping, diff --git a/packages/core/src/tracing/ai/suppression.ts b/packages/core/src/tracing/ai/suppression.ts new file mode 100644 index 000000000000..f4f120c169df --- /dev/null +++ b/packages/core/src/tracing/ai/suppression.ts @@ -0,0 +1,34 @@ +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. + * + * Suppression rides on a forked scope (as `suppressTracing` does) because a + * forked scope is the only Sentry-native carrier that both survives async + * boundaries and stays isolated from concurrent, unrelated work. + * + * Trade-off: current-scope mutations made inside `callback` land on the fork + * and do not propagate to the outer scope. + * + * @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 64cd105905bc..eb3ac9e913fa 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -20,6 +21,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 { resolveAIRecordingOptions, @@ -280,6 +282,12 @@ function instrumentMethod( // would on an uninstrumented client. Fall back to the wrap-time owner for unbound calls. const invocationThis = thisArg !== undefined ? thisArg : context; + // A higher-level integration (e.g. LangChain) is recording the span itself, so skip the + // provider span to avoid a duplicate. + if (_INTERNAL_isAiProviderSpanSuppressed()) { + return target.apply(invocationThis, args); + } + const isStreamingMethod = instrumentedMethod.streaming === true; // If this is the SDK's internal `create` delegation from a streaming helper (e.g. 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 f6f70280e2ac..36de6a89341a 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'; @@ -93,12 +94,17 @@ export function instrumentEmbeddingMethod( return new Proxy(originalMethod, { apply(target, thisArg, args: unknown[]): Promise { return startSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(thisArg, args[0], options), () => { - 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; + }, + ); }); }, }) as (...args: unknown[]) => Promise; 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/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 Date: Tue, 21 Jul 2026 10:38:26 +0200 Subject: [PATCH 2/2] docs(ai): Document provider span suppression in skill + add Bugbot rule Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/add-ai-integration/SKILL.md | 14 +++++++++++--- .cursor/BUGBOT.md | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.agents/skills/add-ai-integration/SKILL.md b/.agents/skills/add-ai-integration/SKILL.md index 8323aa86fd56..7a31a6d26261 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,17 @@ 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 + +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`: + +- **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) **Mandatory** for Node.js AI integrations. OTel only patches when the package is imported (zero cost if unused). @@ -99,6 +106,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..7f737091e9e1 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -45,6 +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 (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.