diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-platform-client.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-platform-client.mjs new file mode 100644 index 000000000000..44d6ab87f579 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-platform-client.mjs @@ -0,0 +1,55 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +// `AnthropicBedrock` (`@anthropic-ai/bedrock-sdk`) is a `BaseAnthropic` subclass that names its platform this way +// and reuses the SDK's `Messages` resource. +class BedrockClient extends Anthropic { + constructor(options) { + super(options); + this._genAIProviderName = 'aws.bedrock'; + } +} + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_bedrock', + type: 'message', + role: 'assistant', + model: req.body.model, + content: [{ type: 'text', text: 'Hello!' }], + 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 startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new BedrockClient({ apiKey: 'mock-api-key', baseURL: `http://localhost:${server.address().port}` }); + await client.messages.create({ + model: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + max_tokens: 100, + messages: [{ role: 'user', content: 'Hello' }], + }); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts index afda0052828f..14ae36fa4292 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts @@ -1,6 +1,7 @@ import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID, + GEN_AI_PROVIDER_NAME, GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING, GEN_AI_RESPONSE_TEXT, @@ -289,4 +290,19 @@ describe('Anthropic integration (1.x)', () => { }); } }); + + createEsmAndCjsTests(__dirname, 'scenario-platform-client.mjs', 'instrument.mjs', (createRunner, test) => { + test('uses the provider name of a platform client such as AnthropicBedrock', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const chatSpan = spanForCall(container.items, 'msg_bedrock'); + expect(chatSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('aws.bedrock'); + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index 6b4f7d357ce6..eea9820cd329 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -29,7 +29,7 @@ import { ANTHROPIC_AI_ORIGIN, ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import { instrumentSessionToolRunner, instrumentToolRunner, instrumentToolRunnerFactory } from './tool-runner'; import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; -import { getMessagesAttributes, handleResponseError, messagesFromParams } from './utils'; +import { getMessagesAttributes, getProviderName, handleResponseError, messagesFromParams } from './utils'; // Set only while a streaming helper (e.g. `messages.stream()`) synchronously delegates to the // underlying `create`. The SDK invokes that internal `create` synchronously, so a plain flag @@ -47,9 +47,10 @@ export function extractRequestAttributes( args: unknown[], operationName: string, recordInputs: boolean, + providerName = 'anthropic', ): Record { const attributes: Record = { - [GEN_AI_PROVIDER_NAME]: 'anthropic', + [GEN_AI_PROVIDER_NAME]: providerName, [GEN_AI_OPERATION_NAME]: operationName, [SENTRY_ORIGIN]: ANTHROPIC_AI_ORIGIN, }; @@ -261,7 +262,12 @@ function instrumentMethod( } const operationName = instrumentedMethod.operation || 'unknown'; - const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs); + const requestAttributes = extractRequestAttributes( + args, + operationName, + !!options.recordInputs, + getProviderName(invocationThis), + ); const model = requestAttributes[GEN_AI_REQUEST_MODEL] || 'unknown'; const client = getClient(); // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. diff --git a/packages/server-utils/src/ai/anthropic-ai/utils.ts b/packages/server-utils/src/ai/anthropic-ai/utils.ts index 491b357d79a7..4aa0a35972e6 100644 --- a/packages/server-utils/src/ai/anthropic-ai/utils.ts +++ b/packages/server-utils/src/ai/anthropic-ai/utils.ts @@ -4,6 +4,16 @@ import { GEN_AI_INPUT_MESSAGES, GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conve import { extractSystemInstructions } from '../core/utils'; import type { AnthropicAiResponse } from './types'; +/** + * The `gen_ai.provider.name` of the client that owns an SDK resource such as `messages`. The SDK's clients + * for other platforms set their own, e.g. `aws.bedrock` for `AnthropicBedrock`. + */ +export function getProviderName(resource: unknown): string { + const name = (resource as { _client?: { _genAIProviderName?: unknown } } | null | undefined)?._client + ?._genAIProviderName; + return typeof name === 'string' && name ? name : 'anthropic'; +} + /** * Get the input messages attribute, with system instructions extracted into their own attribute. */ diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index 4dfbff811c52..7b9251342c5c 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -6,6 +6,7 @@ import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../ai/anthropic-ai'; import { ANTHROPIC_AI_ORIGIN } from '../ai/anthropic-ai/constants'; +import { getProviderName } from '../ai/anthropic-ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../ai/anthropic-ai/streaming'; import { instrumentSessionToolRunner, instrumentToolRunner } from '../ai/anthropic-ai/tool-runner'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../ai/anthropic-ai/types'; @@ -134,7 +135,7 @@ function createGenAiSpan( const { recordInputs } = resolveAIRecordingOptions(options); - const attributes = extractRequestAttributes(args, operation, recordInputs); + const attributes = extractRequestAttributes(args, operation, recordInputs, getProviderName(data.self)); const model = (attributes[GEN_AI_REQUEST_MODEL] as string) || 'unknown'; attributes[SENTRY_ORIGIN] = ANTHROPIC_AI_ORIGIN; const client = getClient(); diff --git a/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts b/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts index 1a08d660ab7d..18799c8e871d 100644 --- a/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts @@ -89,3 +89,39 @@ describe('instrumentAnthropicAiClient span names', () => { expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); }); }); + +describe('instrumentAnthropicAiClient provider name', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + it.each([ + { name: 'uses the provider name of a platform client', providerName: 'aws.bedrock', expected: 'aws.bedrock' }, + { name: 'falls back to anthropic', providerName: undefined, expected: 'anthropic' }, + ])('$name', async ({ providerName, expected }) => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1 }), + ); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + + const anthropic = { + _genAIProviderName: providerName, + messages: { create: vi.fn().mockResolvedValue({ id: 'msg', content: [] }) } as Record, + }; + anthropic.messages._client = anthropic; + + await instrumentAnthropicAiClient(anthropic).messages.create({ + model: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).data['gen_ai.provider.name']).toBe(expected); + }); +}); diff --git a/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts b/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts index e6eae3860949..f60992f7e78d 100644 --- a/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts @@ -1,11 +1,29 @@ import { describe, expect, it } from 'vitest'; import { getMessagesAttributes, + getProviderName, mapAnthropicErrorToStatusMessage, messagesFromParams, } from '../../../../src/ai/anthropic-ai/utils'; describe('anthropic-ai-utils', () => { + describe('getProviderName', () => { + it('reads the provider name of the client that owns the resource', () => { + expect(getProviderName({ _client: { _genAIProviderName: 'aws.bedrock' } })).toBe('aws.bedrock'); + }); + + it.each([ + undefined, + null, + {}, + { _client: {} }, + { _client: { _genAIProviderName: '' } }, + { _client: { _genAIProviderName: 1 } }, + ])('falls back to anthropic for %j', resource => { + expect(getProviderName(resource)).toBe('anthropic'); + }); + }); + describe('mapAnthropicErrorToStatusMessage', () => { it('maps known Anthropic error types to SpanStatusType values', () => { expect(mapAnthropicErrorToStatusMessage('invalid_request_error')).toBe('invalid_argument');