Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
import * as Sentry from '@sentry/node';
import express from 'express';

function startMockAnthropicServer() {
const app = express();
app.use(express.json());

app.post('/v1/messages', (req, res) => {
res.json({
id: 'msg_test123',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: 'Mock response from Anthropic!',
},
],
model: req.body.model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 15,
},
});
});

return new Promise(resolve => {
const server = app.listen(0, () => {
resolve(server);
});
});
}

async function run() {
const server = await startMockAnthropicServer();
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
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const anthropicClient = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

// Use the Anthropic client directly - this will be instrumented by the Anthropic integration
await anthropicClient.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});

// NOW import LangChain - at this point it will mark Anthropic to be skipped
// But the client created above is already instrumented
const { ChatAnthropic } = await import('@langchain/anthropic');

// Create a LangChain model - this uses Anthropic under the hood
const langchainModel = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
temperature: 0.7,
maxTokens: 100,
apiKey: 'mock-api-key',
clientOptions: {
baseURL,
},
});

// 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)
const anthropicClient2 = new Anthropic({
apiKey: 'mock-api-key',
baseURL,
});

await anthropicClient2.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Second direct Anthropic call' }],
temperature: 0.7,
max_tokens: 100,
});
});

await Sentry.flush(2000);
server.close();
}

run();
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,4 +245,64 @@ describe('LangChain integration', () => {
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-openai-before-langchain.mjs',
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans (ESM only)', 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(
span =>
span.description === 'messages 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(
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 === 'messages 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');

// - 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);
},
})
.start()
.completed();
});
},
// This test fails on CJS because we use dynamic imports to simulate importing LangChain after the Anthropic client is created
{ failsOnCjs: true },
);
});
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,12 @@ 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 } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent';
export { prepareEvent } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,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
64 changes: 64 additions & 0 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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<string>();

/**
* 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');
}
6 changes: 3 additions & 3 deletions packages/core/test/lib/integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { getCurrentScope } from '../../src/currentScopes';
import { 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 DownExpand Up@@ -32,8 +32,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
71 changes: 71 additions & 0 deletions packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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);
});
});
});
18 changes: 17 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line numberDiff line numberDiff 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 {
_INTERNAL_clearAiProviderSkips,
_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 DownExpand Up@@ -145,6 +152,15 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
}
}

/** @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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Reinit Breaks Instrumentation Skip Logic

Calling _INTERNAL_clearAiProviderSkips() in _setupIntegrations() can cause incorrect instrumentation when Sentry.init() is called multiple times. If LangChain was imported during the first initialization and marked AI providers to skip, a second initialization clears those markers. Since LangChain's _patch won't run again (module already loaded), new AI provider client instances created after the second init will be incorrectly instrumented, even though LangChain is active. The skip markers should persist across client reinitializations or only be cleared when LangChain's patch logic runs.

Fix in CursorFix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the approach taken, should probably be fine in most cases.


/** Custom implementation for OTEL, so we can handle scope-span linking. */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
Expand Down
Loading