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 number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
transport: loggingTransport,
// Filter out Anthropic integration to avoid duplicate spans with LangChain
integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'),
beforeSendTransaction: event => {
// Filter out mock express server transactions
if (event.transaction.includes('/v1/messages')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: false,
transport: loggingTransport,
// Filter out Anthropic integration to avoid duplicate spans with LangChain
integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'),
beforeSendTransaction: event => {
// Filter out mock express server transactions
if (event.transaction.includes('/v1/messages')) {
Expand Down
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { trace } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core';
import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core';
import {
_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 Down Expand 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

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 Cursor Fix in Web

Copy link
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