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
@@ -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();
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
});
});
});
12 changes: 9 additions & 3 deletions packages/server-utils/src/ai/anthropic-ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,9 +47,10 @@ export function extractRequestAttributes(
args: unknown[],
operationName: string,
recordInputs: boolean,
providerName = 'anthropic',
): Record<string, unknown> {
const attributes: Record<string, unknown> = {
[GEN_AI_PROVIDER_NAME]: 'anthropic',
[GEN_AI_PROVIDER_NAME]: providerName,
[GEN_AI_OPERATION_NAME]: operationName,
[SENTRY_ORIGIN]: ANTHROPIC_AI_ORIGIN,
};
Expand Down Expand Up @@ -261,7 +262,12 @@ function instrumentMethod<T extends unknown[], R>(
}

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.
Expand Down
10 changes: 10 additions & 0 deletions packages/server-utils/src/ai/anthropic-ai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/server-utils/src/integrations/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
};
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test uses hardcoded attribute name

Low Severity

The new unit test asserts gen_ai.provider.name with a string literal. Review rules ask span-attribute assertions in tests to use the matching @sentry/conventions/attributes constant, which sibling tests in this package already do. Flagged because that convention is in the review rules.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 308099d. Configure here.

});
});
18 changes: 18 additions & 0 deletions packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
Loading