Skip to content
Draft
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
@@ -0,0 +1,58 @@
import Anthropic from '@anthropic-ai/sdk';
import { ChatAnthropic } from '@langchain/anthropic';
import * as Sentry from '@sentry/node';
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
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));
});
}

// No top-level await: the scenario also runs transpiled to CJS.
startMockAnthropicServer().then(mockServer => {
const baseURL = `http://localhost:${mockServer.address().port}`;

const app = express();

app.get('/langchain', async (_req, res) => {
const model = new ChatAnthropic({
model: 'claude-3-5-sonnet-20241022',
apiKey: 'mock-api-key',
clientOptions: { baseURL },
});
await model.invoke('LangChain Anthropic call');
res.send({ message: 'OK' });
});

app.get('/direct', async (_req, res) => {
const client = new Anthropic({ apiKey: 'mock-api-key', baseURL });
await client.messages.create({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Direct Anthropic call' }],
max_tokens: 100,
});
res.send({ message: 'OK' });
});

Sentry.setupExpressErrorHandler(app);

startExpressServerAndSendPortToRunner(app);
});
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,42 @@ describe('LangChain integration', () => {
});
});

createEsmAndCjsTests(
__dirname,
'scenario-direct-after-langchain-express.mjs',
'instrument.mjs',
(createRunner, test) => {
test('keeps instrumenting direct provider calls in requests after a LangChain request', async () => {
const runner = createRunner()
// The transaction and span envelopes of a request can arrive in either order.
.unordered()
.expect({ transaction: { transaction: 'GET /langchain' } })
.expect({
span: container => {
expect(container.items).toHaveLength(1);
expect(container.items[0]!.name).toBe('chat claude-3-5-sonnet-20241022');
expect(container.items[0]!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
},
})
.expect({ transaction: { transaction: 'GET /direct' } })
.expect({
span: container => {
expect(container.items).toHaveLength(1);
expect(container.items[0]!.name).toBe('chat claude-3-5-sonnet-20241022');
expect(container.items[0]!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic');
},
})
.start();

// The LangChain request marks Anthropic as skipped for its own invocation only; the direct
// call of the next request must still get its span.
await runner.makeRequest('get', '/langchain');
await runner.makeRequest('get', '/direct');
await runner.completed();
});
},
);

createEsmAndCjsTests(
__dirname,
'scenario-system-instructions.mjs',
Expand Down
11 changes: 0 additions & 11 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core';
import {
_INTERNAL_clearAiProviderSkips,
applySdkMetadata,
debug,
ServerRuntimeClient,
Expand Down Expand Up @@ -146,16 +145,6 @@ export class CloudflareClient extends ServerRuntimeClient {
(this as unknown as { _flushLock: ReturnType<typeof makeFlushLock> | void })._flushLock = undefined;
}

/** @inheritDoc */
protected override _setupIntegrations(): void {
// Clear AI provider skip registrations before setting up integrations.
// The registry is module-global and Cloudflare calls `init()` per request, so without this a
// single `ai` SDK call would suppress direct `env.AI.run` spans for the rest of the isolate's
// life. Mirrors the same reset in the Node client.
_INTERNAL_clearAiProviderSkips();
super._setupIntegrations();
}

/**
* Resets the span completion promise and resolve function.
*/
Expand Down
45 changes: 31 additions & 14 deletions packages/core/src/utils/ai/providerSkip.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { getIsolationScope } from '../../currentScopes';
import { DEBUG_BUILD } from '../../debug-build';
import { getDefaultIsolationScope } from '../../defaultScopes';
import type { Scope } from '../../scope';
import { debug } from '../debug-logger';

/**
* Registry tracking which AI provider modules should skip instrumentation wrapping.
* AI provider modules that should skip instrumentation wrapping, per isolation scope.
*
* This prevents duplicate spans when a higher-level integration (like LangChain)
* already instruments AI providers at a higher abstraction level.
* Skips are registered lazily by a higher-level integration (like LangChain) once it drives a
* provider, so they are bound to the invocation that registered them. A module-global set would
* outlive the invocation on runtimes where a client serves many invocations (Cloudflare isolates,
* Node processes) and suppress spans for direct provider calls made by later, unrelated invocations.
*/
const SKIPPED_AI_PROVIDERS = new Set<string>();
const SKIPPED_AI_PROVIDERS = new WeakMap<Scope, Set<string>>();

function getSkips(scope: Scope): Set<string> | undefined {
return SKIPPED_AI_PROVIDERS.get(scope);
}

/**
* Mark AI provider modules to skip instrumentation wrapping.
* Mark AI provider modules to skip instrumentation wrapping for the current isolation scope.
*
* This prevents duplicate spans when a higher-level integration (like LangChain)
* already instruments AI providers at a higher abstraction level.
Expand All @@ -25,15 +34,25 @@ const SKIPPED_AI_PROVIDERS = new Set<string>();
* ```
*/
export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void {
modules.forEach(module => {
SKIPPED_AI_PROVIDERS.add(module);
const scope = getIsolationScope();
let skips = getSkips(scope);
if (!skips) {
skips = new Set();
SKIPPED_AI_PROVIDERS.set(scope, skips);
}

for (const module of modules) {
skips.add(module);
DEBUG_BUILD && debug.log(`AI provider "${module}" wrapping will be skipped`);
});
}
}

/**
* Check if an AI provider module should skip instrumentation wrapping.
*
* A skip registered inside an invocation applies to that invocation; one registered outside any
* invocation (on the default isolation scope) applies everywhere.
*
* @internal
* @param module - The npm module name (e.g., '@anthropic-ai/sdk', 'openai')
* @returns true if wrapping should be skipped
Expand All @@ -47,18 +66,16 @@ export function _INTERNAL_skipAiProviderWrapping(modules: string[]): void {
* ```
*/
export function _INTERNAL_shouldSkipAiProviderWrapping(module: string): boolean {
return SKIPPED_AI_PROVIDERS.has(module);
return !!getSkips(getIsolationScope())?.has(module) || !!getSkips(getDefaultIsolationScope())?.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.
* Clear the AI provider skip registrations of the current and the default isolation scope.
*
* @internal
*/
export function _INTERNAL_clearAiProviderSkips(): void {
SKIPPED_AI_PROVIDERS.clear();
SKIPPED_AI_PROVIDERS.delete(getIsolationScope());
SKIPPED_AI_PROVIDERS.delete(getDefaultIsolationScope());
DEBUG_BUILD && debug.log('Cleared AI provider skip registrations');
}
81 changes: 80 additions & 1 deletion packages/core/test/lib/utils/ai/providerSkip.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Scope } from '../../../../src/index';
import {
_INTERNAL_clearAiProviderSkips,
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_skipAiProviderWrapping,
getAsyncContextStrategy,
getDefaultIsolationScope,
getMainCarrier,
setAsyncContextStrategy,
} from '../../../../src/index';

const OPENAI_INTEGRATION_NAME = 'OpenAI';
Expand Down Expand Up @@ -49,6 +54,80 @@ describe('AI Provider Skip', () => {
});
});

describe('isolation scope binding', () => {
// The stack strategy never forks the isolation scope, so the tests install one that does,
// the way the Node and Cloudflare strategies fork one per invocation.
let isolationScope: Scope;

function withInvocation<T>(callback: () => T): T {
const previous = isolationScope;
isolationScope = previous.clone();
try {
return callback();
} finally {
isolationScope = previous;
}
}

beforeEach(() => {
isolationScope = getDefaultIsolationScope();
setAsyncContextStrategy({
...getAsyncContextStrategy(getMainCarrier()),
getIsolationScope: () => isolationScope,
});
});

afterEach(() => {
setAsyncContextStrategy(undefined);
_INTERNAL_clearAiProviderSkips();
});

it('binds a skip registered inside an invocation to that invocation', () => {
withInvocation(() => {
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
});

expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
withInvocation(() => {
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
});
});

it('applies a skip registered outside any invocation inside every invocation', () => {
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);

withInvocation(() => {
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(false);
});
});

it('does not let a skip from one invocation leak into a nested one', () => {
withInvocation(() => {
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);

withInvocation(() => {
expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false);
});

expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
});
});

it('clears only the current and the default isolation scope', () => {
withInvocation(() => {
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]);

withInvocation(() => {
_INTERNAL_clearAiProviderSkips();
});

expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true);
});
});
});

describe('_INTERNAL_clearAiProviderSkips', () => {
it('clears all skip registrations', () => {
_INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME]);
Expand Down
10 changes: 0 additions & 10 deletions packages/node/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { Tracer } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import type { ServerRuntimeClientOptions } from '@sentry/core';
import {
_INTERNAL_clearAiProviderSkips,
_INTERNAL_flushLogsBuffer,
_INTERNAL_setDeferSegmentSpanCapture,
applySdkMetadata,
Expand Down Expand Up @@ -178,13 +177,4 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
process.on('beforeExit', this._clientReportOnExitFlushListener);
}
}

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