Skip to content

Commit 2406e90

Browse files
feat(node-core): Add mechanism to prevent wrapping ai providers multiple times(#17972)
When using higher-level integrations that wrap underlying libraries, both the wrapper integration and the underlying library integration can instrument the same API calls, resulting in duplicate spans. This is particularly problematic for: - LangChain wrapping AI providers (OpenAI, Anthropic, Google GenAI) - Any future providers that wrap other providers We expose 3 internal methods ```js _INTERNAL_skipAiProviderWrapping(providers: string[]) _INTERNAL_shouldSkipAiProviderWrapping(provider: string) _INTERNAL_clearAiProviderSkips() ``` To bail out of instrumenting providers when they are on the skip list. These are internal methods not meant for public consumers and may be changed or removed in the future. --------- Co-authored-by: Andrei Borza <andrei.borza@sentry.io>
1 parent 0cb306e commit 2406e90

File tree

16 files changed

+381
-22
lines changed

16 files changed

+381
-22
lines changed

dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ Sentry.init({
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
99
transport: loggingTransport,
10-
// Filter out Anthropic integration to avoid duplicate spans with LangChain
11-
integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'),
1210
beforeSendTransaction: event => {
1311
// Filter out mock express server transactions
1412
if (event.transaction.includes('/v1/messages')) {

dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ Sentry.init({
77
tracesSampleRate: 1.0,
88
sendDefaultPii: false,
99
transport: loggingTransport,
10-
// Filter out Anthropic integration to avoid duplicate spans with LangChain
11-
integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'),
1210
beforeSendTransaction: event => {
1311
// Filter out mock express server transactions
1412
if (event.transaction.includes('/v1/messages')) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as Sentry from '@sentry/node';
2+
import express from 'express';
3+
4+
function startMockAnthropicServer() {
5+
const app = express();
6+
app.use(express.json());
7+
8+
app.post('/v1/messages', (req, res) => {
9+
res.json({
10+
id: 'msg_test123',
11+
type: 'message',
12+
role: 'assistant',
13+
content: [
14+
{
15+
type: 'text',
16+
text: 'Mock response from Anthropic!',
17+
},
18+
],
19+
model: req.body.model,
20+
stop_reason: 'end_turn',
21+
stop_sequence: null,
22+
usage: {
23+
input_tokens: 10,
24+
output_tokens: 15,
25+
},
26+
});
27+
});
28+
29+
return new Promise(resolve => {
30+
const server = app.listen(0, () => {
31+
resolve(server);
32+
});
33+
});
34+
}
35+
36+
async function run() {
37+
const server = await startMockAnthropicServer();
38+
const baseURL = `http://localhost:${server.address().port}`;
39+
40+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
41+
// EDGE CASE: Import and instantiate Anthropic client BEFORE LangChain is imported
42+
// This simulates the timing issue where a user creates an Anthropic client in one file
43+
// before importing LangChain in another file
44+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
45+
const anthropicClient = new Anthropic({
46+
apiKey: 'mock-api-key',
47+
baseURL,
48+
});
49+
50+
// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
51+
await anthropicClient.messages.create({
52+
model: 'claude-3-5-sonnet-20241022',
53+
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
54+
temperature: 0.7,
55+
max_tokens: 100,
56+
});
57+
58+
// NOW import LangChain - at this point it will mark Anthropic to be skipped
59+
// But the client created above is already instrumented
60+
const { ChatAnthropic } = await import('@langchain/anthropic');
61+
62+
// Create a LangChain model - this uses Anthropic under the hood
63+
const langchainModel = new ChatAnthropic({
64+
model: 'claude-3-5-sonnet-20241022',
65+
temperature: 0.7,
66+
maxTokens: 100,
67+
apiKey: 'mock-api-key',
68+
clientOptions: {
69+
baseURL,
70+
},
71+
});
72+
73+
// Use LangChain - this will be instrumented by LangChain integration
74+
await langchainModel.invoke('LangChain Anthropic call');
75+
76+
// Create ANOTHER Anthropic client after LangChain was imported
77+
// This one should NOT be instrumented (skip mechanism works correctly)
78+
const anthropicClient2 = new Anthropic({
79+
apiKey: 'mock-api-key',
80+
baseURL,
81+
});
82+
83+
await anthropicClient2.messages.create({
84+
model: 'claude-3-5-sonnet-20241022',
85+
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
86+
temperature: 0.7,
87+
max_tokens: 100,
88+
});
89+
});
90+
91+
await Sentry.flush(2000);
92+
server.close();
93+
}
94+
95+
run();

dev-packages/node-integration-tests/suites/tracing/langchain/test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
245245
});
246246
},
247247
);
248+
249+
createEsmAndCjsTests(
250+
__dirname,
251+
'scenario-openai-before-langchain.mjs',
252+
'instrument.mjs',
253+
(createRunner, test) => {
254+
test('demonstrates timing issue with duplicate spans (ESM only)', async () => {
255+
await createRunner()
256+
.ignore('event')
257+
.expect({
258+
transaction: event => {
259+
// This test highlights the limitation: if a user creates an Anthropic client
260+
// before importing LangChain, that client will still be instrumented and
261+
// could cause duplicate spans when used alongside LangChain.
262+
263+
const spans = event.spans || [];
264+
265+
// First call: Direct Anthropic call made BEFORE LangChain import
266+
// This should have Anthropic instrumentation (origin: 'auto.ai.anthropic')
267+
const firstAnthropicSpan = spans.find(
268+
span =>
269+
span.description === 'messages claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic',
270+
);
271+
272+
// Second call: LangChain call
273+
// This should have LangChain instrumentation (origin: 'auto.ai.langchain')
274+
const langchainSpan = spans.find(
275+
span => span.description === 'chat claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.langchain',
276+
);
277+
278+
// Third call: Direct Anthropic call made AFTER LangChain import
279+
// This should NOT have Anthropic instrumentation (skip works correctly)
280+
// Count how many Anthropic spans we have - should be exactly 1
281+
const anthropicSpans = spans.filter(
282+
span =>
283+
span.description === 'messages claude-3-5-sonnet-20241022' && span.origin === 'auto.ai.anthropic',
284+
);
285+
286+
// Verify the edge case limitation:
287+
// - First Anthropic client (created before LangChain) IS instrumented
288+
expect(firstAnthropicSpan).toBeDefined();
289+
expect(firstAnthropicSpan?.origin).toBe('auto.ai.anthropic');
290+
291+
// - LangChain call IS instrumented by LangChain
292+
expect(langchainSpan).toBeDefined();
293+
expect(langchainSpan?.origin).toBe('auto.ai.langchain');
294+
295+
// - Second Anthropic client (created after LangChain) is NOT instrumented
296+
// This demonstrates that the skip mechanism works for NEW clients
297+
// We should only have ONE Anthropic span (the first one), not two
298+
expect(anthropicSpans).toHaveLength(1);
299+
},
300+
})
301+
.start()
302+
.completed();
303+
});
304+
},
305+
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
306+
{ failsOnCjs: true },
307+
);
248308
});

packages/core/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ export { initAndBind, setCurrentClient } from './sdk';
5555
export { createTransport } from './transports/base';
5656
export { makeOfflineTransport } from './transports/offline';
5757
export { makeMultiplexedTransport } from './transports/multiplexed';
58-
export { getIntegrationsToSetup, addIntegration, defineIntegration } from './integration';
58+
export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration';
59+
export {
60+
_INTERNAL_skipAiProviderWrapping,
61+
_INTERNAL_shouldSkipAiProviderWrapping,
62+
_INTERNAL_clearAiProviderSkips,
63+
} from './utils/ai/providerSkip';
5964
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
6065
export { prepareEvent } from './utils/prepareEvent';
6166
export { createCheckInEnvelope } from './checkin';

packages/core/src/integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function setupIntegration(client: Client, integration: Integration, integ
107107
integrationIndex[integration.name] = integration;
108108

109109
// `setupOnce` is only called the first time
110-
if (installedIntegrations.indexOf(integration.name) === -1 && typeof integration.setupOnce === 'function') {
110+
if (!installedIntegrations.includes(integration.name) && typeof integration.setupOnce === 'function') {
111111
integration.setupOnce();
112112
installedIntegrations.push(integration.name);
113113
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { DEBUG_BUILD } from '../../debug-build';
2+
import { debug } from '../debug-logger';
3+
4+
/**
5+
* Registry tracking which AI provider modules should skip instrumentation wrapping.
6+
*
7+
* This prevents duplicate spans when a higher-level integration (like LangChain)
8+
* already instruments AI providers at a higher abstraction level.
9+
*/
10+
const SKIPPED_AI_PROVIDERS = new Set<string>();
11+
12+
/**
13+
* Mark AI provider modules to skip instrumentation wrapping.
14+
*
15+
* This prevents duplicate spans when a higher-level integration (like LangChain)
16+
* already instruments AI providers at a higher abstraction level.
17+
*
18+
* @internal
19+
* @param modules - Array of npm module names to skip (e.g., '@anthropic-ai/sdk', 'openai')
20+
*
21+
* @example
22+
* ```typescript
23+
* // In LangChain integration
24+
* _INTERNAL_skipAiProviderWrapping(['@anthropic-ai/sdk', 'openai', '@google/generative-ai']);
25+
* ```
26+
*/
27+
export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void {
28+
modules.forEach(module => {
29+
SKIPPED_AI_PROVIDERS.add(module);
30+
DEBUG_BUILD && debug.log(`AI provider "${module}" wrapping will be skipped`);
31+
});
32+
}
33+
34+
/**
35+
* Check if an AI provider module should skip instrumentation wrapping.
36+
*
37+
* @internal
38+
* @param module - The npm module name (e.g., '@anthropic-ai/sdk', 'openai')
39+
* @returns true if wrapping should be skipped
40+
*
41+
* @example
42+
* ```typescript
43+
* // In AI provider instrumentation
44+
* if (_INTERNAL_shouldSkipAiProviderWrapping('@anthropic-ai/sdk')) {
45+
* return Reflect.construct(Original, args); // Don't instrument
46+
* }
47+
* ```
48+
*/
49+
export function _INTERNAL_shouldSkipAiProviderWrapping(module: string): boolean {
50+
return SKIPPED_AI_PROVIDERS.has(module);
51+
}
52+
53+
/**
54+
* Clear all AI provider skip registrations.
55+
*
56+
* This is automatically called at the start of Sentry.init() to ensure a clean state
57+
* between different client initializations.
58+
*
59+
* @internal
60+
*/
61+
export function _INTERNAL_clearAiProviderSkips(): void {
62+
SKIPPED_AI_PROVIDERS.clear();
63+
DEBUG_BUILD && debug.log('Cleared AI provider skip registrations');
64+
}

packages/core/test/lib/integration.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
33
import { addIntegration, getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration';
44
import { setCurrentClient } from '../../src/sdk';
55
import type { Integration } from '../../src/types-hoist/integration';
6-
import type { Options } from '../../src/types-hoist/options';
6+
import type { CoreOptions } from '../../src/types-hoist/options';
77
import { debug } from '../../src/utils/debug-logger';
88
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
99

@@ -32,8 +32,8 @@ class MockIntegration implements Integration {
3232

3333
type TestCase = [
3434
string, // test name
35-
Options['defaultIntegrations'], // default integrations
36-
Options['integrations'], // user-provided integrations
35+
CoreOptions['defaultIntegrations'], // default integrations
36+
CoreOptions['integrations'], // user-provided integrations
3737
Array<string | string[]>, // expected results
3838
];
3939

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import {
3+
_INTERNAL_clearAiProviderSkips,
4+
_INTERNAL_shouldSkipAiProviderWrapping,
5+
_INTERNAL_skipAiProviderWrapping,
6+
ANTHROPIC_AI_INTEGRATION_NAME,
7+
GOOGLE_GENAI_INTEGRATION_NAME,
8+
OPENAI_INTEGRATION_NAME,
9+
} from '../../../../src/index';
10+
11+
describe('AI Provider Skip', () => {
12+
beforeEach(() => {
13+
_INTERNAL_clearAiProviderSkips();
14+
});
15+
16+
describe('_INTERNAL_skipAiProviderWrapping', () => {
17+
it('marks a single provider to be skipped', () => {
18+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
19+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
20+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false);
21+
});
22+
23+
it('marks multiple providers to be skipped', () => {
24+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]);
25+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
26+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true);
27+
expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false);
28+
});
29+
30+
it('is idempotent - can mark same provider multiple times', () => {
31+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
32+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
33+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
34+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
35+
});
36+
});
37+
38+
describe('_INTERNAL_shouldSkipAiProviderWrapping', () => {
39+
it('returns false for unmarked providers', () => {
40+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
41+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false);
42+
expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(false);
43+
});
44+
45+
it('returns true after marking provider to be skipped', () => {
46+
_INTERNAL_skipAiProviderWrapping([ANTHROPIC_AI_INTEGRATION_NAME]);
47+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true);
48+
});
49+
});
50+
51+
describe('_INTERNAL_clearAiProviderSkips', () => {
52+
it('clears all skip registrations', () => {
53+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]);
54+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
55+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true);
56+
57+
_INTERNAL_clearAiProviderSkips();
58+
59+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
60+
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false);
61+
});
62+
63+
it('can be called multiple times safely', () => {
64+
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
65+
_INTERNAL_clearAiProviderSkips();
66+
_INTERNAL_clearAiProviderSkips();
67+
_INTERNAL_clearAiProviderSkips();
68+
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
69+
});
70+
});
71+
});

packages/node-core/src/sdk/client.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { trace } from '@opentelemetry/api';
44
import { registerInstrumentations } from '@opentelemetry/instrumentation';
55
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
66
import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core';
7-
import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core';
7+
import {
8+
_INTERNAL_clearAiProviderSkips,
9+
_INTERNAL_flushLogsBuffer,
10+
applySdkMetadata,
11+
debug,
12+
SDK_VERSION,
13+
ServerRuntimeClient,
14+
} from '@sentry/core';
815
import { getTraceContextForScope } from '@sentry/opentelemetry';
916
import { isMainThread, threadId } from 'worker_threads';
1017
import { DEBUG_BUILD } from '../debug-build';
@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
145152
}
146153
}
147154

155+
/** @inheritDoc */
156+
protected _setupIntegrations(): void {
157+
// Clear AI provider skip registrations before setting up integrations
158+
// This ensures a clean state between different client initializations
159+
// (e.g., when LangChain skips OpenAI in one client, but a subsequent client uses OpenAI standalone)
160+
_INTERNAL_clearAiProviderSkips();
161+
super._setupIntegrations();
162+
}
163+
148164
/** Custom implementation for OTEL, so we can handle scope-span linking. */
149165
protected _getTraceInfoFromScope(
150166
scope: Scope | undefined,

0 commit comments

Comments
 (0)