Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 53 additions & 1 deletion packages/core/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/**
* 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;
Expand Down Expand Up @@ -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);
}
Expand Down
73 changes: 69 additions & 4 deletions packages/core/test/lib/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string | string[]>, // expected results
];

Expand Down Expand Up @@ -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
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -48,6 +54,12 @@ export class SentryAnthropicAiInstrumentation extends InstrumentationBase<Anthro
const config = this.getConfig();

const WrappedAnthropic = function (this: unknown, ...args: unknown[]) {
// Check if disabled at runtime (after module is loaded, in case LangChain marked it)
if (_isIntegrationMarkedDisabled(ANTHROPIC_AI_INTEGRATION_NAME)) {
// Return unwrapped instance - no instrumentation
return Reflect.construct(Original, args) as AnthropicAiClient;
}

const instance = Reflect.construct(Original, args);
const client = getClient();
const defaultPii = Boolean(client?.getOptions().sendDefaultPii);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import {
InstrumentationNodeModuleFile,
} from '@opentelemetry/instrumentation';
import type { GoogleGenAIClient, GoogleGenAIOptions } from '@sentry/core';
import { getClient, instrumentGoogleGenAIClient, replaceExports, SDK_VERSION } from '@sentry/core';
import {
_isIntegrationMarkedDisabled,
getClient,
GOOGLE_GENAI_INTEGRATION_NAME,
instrumentGoogleGenAIClient,
replaceExports,
SDK_VERSION,
} from '@sentry/core';

const supportedVersions = ['>=0.10.0 <2'];

Expand Down Expand Up @@ -65,14 +72,18 @@ export class SentryGoogleGenAiInstrumentation extends InstrumentationBase<Google
}

const WrappedGoogleGenAI = function (this: unknown, ...args: unknown[]): GoogleGenAIClient {
// Check if disabled at runtime (after module is loaded, in case LangChain marked it)
if (_isIntegrationMarkedDisabled(GOOGLE_GENAI_INTEGRATION_NAME)) {
// Return unwrapped instance - no instrumentation
return Reflect.construct(Original, args) as GoogleGenAIClient;
}

const instance = Reflect.construct(Original, args);
const client = getClient();
const defaultPii = Boolean(client?.getOptions().sendDefaultPii);

const typedConfig = config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const recordInputs = typedConfig?.recordInputs ?? defaultPii;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const recordOutputs = typedConfig?.recordOutputs ?? defaultPii;

return instrumentGoogleGenAIClient(instance, {
Expand Down
10 changes: 6 additions & 4 deletions packages/node/src/integrations/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export function getAutoPerformanceIntegrations(): Integration[] {
kafkaIntegration(),
amqplibIntegration(),
lruMemoizerIntegration(),
// AI providers
// LangChain must come first to disable AI provider integrations before they instrument
langChainIntegration(),
vercelAIIntegration(),
openAIIntegration(),
postgresJsIntegration(),
firebaseIntegration(),
anthropicAIIntegration(),
googleGenAIIntegration(),
langChainIntegration(),
postgresJsIntegration(),
firebaseIntegration(),
];
}

Expand Down Expand Up @@ -89,12 +91,12 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) =>
instrumentTedious,
instrumentGenericPool,
instrumentAmqplib,
instrumentLangChain,
instrumentVercelAi,
instrumentOpenAi,
instrumentPostgresJs,
instrumentFirebase,
instrumentAnthropicAi,
instrumentGoogleGenAI,
instrumentLangChain,
];
}
6 changes: 6 additions & 0 deletions packages/node/src/integrations/tracing/langchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
};
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -143,14 +151,16 @@ export class SentryLangChainInstrumentation extends InstrumentationBase<LangChai
* This is called when a LangChain provider package is loaded
*/
private _patch(exports: PatchedLangChainExports): PatchedLangChainExports | void {
// Mark AI provider integrations as disabled now that LangChain is actually being used
// This prevents duplicate spans from Anthropic/OpenAI/GoogleGenAI standalone integrations
_markIntegrationsDisabled([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME]);

const client = getClient();
const defaultPii = Boolean(client?.getOptions().sendDefaultPii);

const config = this.getConfig();

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const recordInputs = config?.recordInputs ?? defaultPii;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const recordOutputs = config?.recordOutputs ?? defaultPii;

// Create a shared handler instance
Expand Down
Loading
Loading