diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs index 85b2a963d977..cb68a6f7683e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs @@ -7,8 +7,6 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - // Filter out Anthropic integration to avoid duplicate spans with LangChain - integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), beforeSendTransaction: event => { // Filter out mock express server transactions if (event.transaction.includes('/v1/messages')) { diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs index 524d19f4b995..b4ce44f3e91a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs @@ -7,8 +7,6 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: false, transport: loggingTransport, - // Filter out Anthropic integration to avoid duplicate spans with LangChain - integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), beforeSendTransaction: event => { // Filter out mock express server transactions if (event.transaction.includes('/v1/messages')) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3b29009b9ce..34f83d1b5f9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,7 +55,15 @@ export { initAndBind, setCurrentClient } from './sdk'; export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; export { makeMultiplexedTransport } from './transports/multiplexed'; -export { getIntegrationsToSetup, addIntegration, defineIntegration } from './integration'; +export { + getIntegrationsToSetup, + addIntegration, + defineIntegration, + installedIntegrations, + _markIntegrationsDisabled, + _isIntegrationMarkedDisabled, + _clearDisabledIntegrationsMarks, +} from './integration'; export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 5cba3ff3dfb8..1bca45ad9321 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -8,6 +8,58 @@ import { debug } from './utils/debug-logger'; export const installedIntegrations: string[] = []; +/** + * Registry to track integrations marked as disabled. + * This is used to prevent duplicate instrumentation when higher-level integrations + * (like LangChain) already instrument the underlying libraries (like OpenAI, Anthropic, etc.) + */ +const MARKED_DISABLED_INTEGRATIONS = new Set(); + +/** + * Mark one or more integrations as disabled to prevent their instrumentation from being set up. + * This should be called during an integration's setupOnce() phase. + * The marked integrations will be skipped when their own setupOnce() is called. + * + * @internal This is an internal API for coordination between integrations, not for public use. + * @param integrationName The name(s) of the integration(s) to mark as disabled + */ +export function _markIntegrationsDisabled(integrationName: string | string[]): void { + const names = Array.isArray(integrationName) ? integrationName : [integrationName]; + names.forEach(name => { + MARKED_DISABLED_INTEGRATIONS.add(name); + DEBUG_BUILD && debug.log(`Integration marked as disabled: ${name}`); + }); +} + +/** + * Check if an integration has been marked as disabled. + * + * @internal This is an internal API for coordination between integrations, not for public use. + * @param integrationName The name of the integration to check + * @returns true if the integration is marked as disabled + */ +export function _isIntegrationMarkedDisabled(integrationName: string): boolean { + return MARKED_DISABLED_INTEGRATIONS.has(integrationName); +} + +/** + * Clear all integration marks and remove marked integrations from the installed list. + * This is automatically called at the start of Sentry.init() to ensure a clean state + * between different client initializations. + * + * This also removes the marked integrations from the global installedIntegrations list, + * allowing them to run setupOnce() again if they're included in a new client. + * + * @internal This is an internal API for coordination between integrations, not for public use. + */ +export function _clearDisabledIntegrationsMarks(): void { + // Remove marked integrations from the installed list so they can setup again + const filtered = installedIntegrations.filter(integration => !MARKED_DISABLED_INTEGRATIONS.has(integration)); + installedIntegrations.splice(0, installedIntegrations.length, ...filtered); + + MARKED_DISABLED_INTEGRATIONS.clear(); +} + /** Map of integrations assigned to a client */ export type IntegrationIndex = { [key: string]: Integration; @@ -107,7 +159,7 @@ export function setupIntegration(client: Client, integration: Integration, integ integrationIndex[integration.name] = integration; // `setupOnce` is only called the first time - if (installedIntegrations.indexOf(integration.name) === -1 && typeof integration.setupOnce === 'function') { + if (!installedIntegrations.includes(integration.name) && typeof integration.setupOnce === 'function') { integration.setupOnce(); installedIntegrations.push(integration.name); } diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 5b7554f261b1..9a3c1ba98f2c 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -1,9 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { getCurrentScope } from '../../src/currentScopes'; -import { addIntegration, getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration'; +import { + _clearDisabledIntegrationsMarks, + _isIntegrationMarkedDisabled, + _markIntegrationsDisabled, + addIntegration, + getIntegrationsToSetup, + installedIntegrations, + setupIntegration, +} from '../../src/integration'; import { setCurrentClient } from '../../src/sdk'; import type { Integration } from '../../src/types-hoist/integration'; -import type { Options } from '../../src/types-hoist/options'; +import type { CoreOptions } from '../../src/types-hoist/options'; import { debug } from '../../src/utils/debug-logger'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; @@ -32,8 +40,8 @@ class MockIntegration implements Integration { type TestCase = [ string, // test name - Options['defaultIntegrations'], // default integrations - Options['integrations'], // user-provided integrations + CoreOptions['defaultIntegrations'], // default integrations + CoreOptions['integrations'], // user-provided integrations Array, // expected results ]; @@ -683,3 +691,60 @@ describe('addIntegration', () => { expect(logs).toHaveBeenCalledWith('Integration skipped because it was already installed: test'); }); }); + +describe('Integration marking system', () => { + beforeEach(() => { + _clearDisabledIntegrationsMarks(); + installedIntegrations.splice(0, installedIntegrations.length); + }); + + afterEach(() => { + _clearDisabledIntegrationsMarks(); + }); + + it('marks and checks single integration', () => { + expect(_isIntegrationMarkedDisabled('TestIntegration')).toBe(false); + + _markIntegrationsDisabled('TestIntegration'); + + expect(_isIntegrationMarkedDisabled('TestIntegration')).toBe(true); + }); + + it('marks and checks multiple integrations', () => { + _markIntegrationsDisabled(['OpenAI', 'Anthropic', 'GoogleGenAI']); + + expect(_isIntegrationMarkedDisabled('OpenAI')).toBe(true); + expect(_isIntegrationMarkedDisabled('Anthropic')).toBe(true); + expect(_isIntegrationMarkedDisabled('GoogleGenAI')).toBe(true); + expect(_isIntegrationMarkedDisabled('Other')).toBe(false); + }); + + it('clears marks and removes from installedIntegrations', () => { + // Simulate scenario: integrations installed, some marked for cleanup + installedIntegrations.push('LangChain', 'OpenAI', 'Anthropic', 'Normal'); + _markIntegrationsDisabled(['OpenAI', 'Anthropic']); + + _clearDisabledIntegrationsMarks(); + + // Marks are cleared + expect(_isIntegrationMarkedDisabled('OpenAI')).toBe(false); + expect(_isIntegrationMarkedDisabled('Anthropic')).toBe(false); + + // Marked integrations removed from installed list (can setup again in new client) + expect(installedIntegrations).toEqual(['LangChain', 'Normal']); + }); + + it('handles multi-client scenario correctly', () => { + // First client with LangChain + installedIntegrations.push('LangChain', 'OpenAI'); + _markIntegrationsDisabled('OpenAI'); + expect(_isIntegrationMarkedDisabled('OpenAI')).toBe(true); + + // Second client initialization clears marks + _clearDisabledIntegrationsMarks(); + + // OpenAI can be used standalone in second client + expect(_isIntegrationMarkedDisabled('OpenAI')).toBe(false); + expect(installedIntegrations).toEqual(['LangChain']); // OpenAI removed, can setup fresh + }); +}); diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index e631508c7392..2cd8810e5c98 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -4,7 +4,14 @@ 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_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; +import { + _clearDisabledIntegrationsMarks, + _INTERNAL_flushLogsBuffer, + applySdkMetadata, + debug, + SDK_VERSION, + ServerRuntimeClient, +} from '@sentry/core'; import { getTraceContextForScope } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; @@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient { } } + /** @inheritDoc */ + protected _setupIntegrations(): void { + // Clear integration marks before setting up integrations + // This ensures that integrations work correctly when not all default integrations are used + // (e.g., when LangChain marks OpenAI as disabled, but a subsequent client doesn't use LangChain) + _clearDisabledIntegrationsMarks(); + 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 d55689415aee..dd5b2c405776 100644 --- a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -5,7 +5,13 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { AnthropicAiClient, AnthropicAiOptions } from '@sentry/core'; -import { getClient, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; +import { + _isIntegrationMarkedDisabled, + ANTHROPIC_AI_INTEGRATION_NAME, + getClient, + instrumentAnthropicAiClient, + SDK_VERSION, +} from '@sentry/core'; const supportedVersions = ['>=0.19.2 <1.0.0']; @@ -48,6 +54,12 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase=0.10.0 <2']; @@ -65,14 +72,18 @@ export class SentryGoogleGenAiInstrumentation extends InstrumentationBase instrumentTedious, instrumentGenericPool, instrumentAmqplib, + instrumentLangChain, instrumentVercelAi, instrumentOpenAi, instrumentPostgresJs, instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, - instrumentLangChain, ]; } diff --git a/packages/node/src/integrations/tracing/langchain/index.ts b/packages/node/src/integrations/tracing/langchain/index.ts index e575691b930f..c374e390a0b5 100644 --- a/packages/node/src/integrations/tracing/langchain/index.ts +++ b/packages/node/src/integrations/tracing/langchain/index.ts @@ -12,6 +12,8 @@ const _langChainIntegration = ((options: LangChainOptions = {}) => { return { name: LANGCHAIN_INTEGRATION_NAME, setupOnce() { + // Register the instrumentation + // The instrumentation will mark AI providers as disabled when LangChain modules are actually loaded instrumentLangChain(options); }, }; @@ -25,6 +27,10 @@ const _langChainIntegration = ((options: LangChainOptions = {}) => { * 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 marks the OpenAI, Anthropic, and Google GenAI + * integrations as disabled to prevent duplicate spans when using LangChain with these providers. + * LangChain handles the instrumentation for all underlying AI providers. + * * @example * ```javascript * import * as Sentry from '@sentry/node'; diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts index f171a2dfb022..e47b8d7eb0ba 100644 --- a/packages/node/src/integrations/tracing/langchain/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -6,7 +6,15 @@ import { InstrumentationNodeModuleFile, } from '@opentelemetry/instrumentation'; import type { LangChainOptions } from '@sentry/core'; -import { createLangChainCallbackHandler, getClient, SDK_VERSION } from '@sentry/core'; +import { + _markIntegrationsDisabled, + ANTHROPIC_AI_INTEGRATION_NAME, + createLangChainCallbackHandler, + getClient, + GOOGLE_GENAI_INTEGRATION_NAME, + OPENAI_INTEGRATION_NAME, + SDK_VERSION, +} from '@sentry/core'; const supportedVersions = ['>=0.1.0 <1.0.0']; @@ -143,14 +151,16 @@ export class SentryLangChainInstrumentation extends InstrumentationBase=4.0.0 <6']; @@ -56,6 +62,12 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase(OPENAI_INTEGRATION_NAME);