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,18 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.experimentalUseDiagnosticsChannelInjection();

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
beforeSendTransaction: event => {
if (event.transaction.includes('/anthropic/v1/')) {
return null;
}
return event;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.experimentalUseDiagnosticsChannelInjection();

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
beforeSendTransaction: event => {
if (event.transaction.includes('/anthropic/v1/')) {
return null;
}
return event;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Anthropic from '@anthropic-ai/sdk';
import * as Sentry from '@sentry/node';
import express from 'express';

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

app.post('/anthropic/v1/messages', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});

const events = [
{
type: 'message_start',
message: {
id: 'msg_stream123',
type: 'message',
role: 'assistant',
model: req.body.model,
content: [],
usage: { input_tokens: 10 },
},
},
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello ' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'from ' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'stream!' } },
{ type: 'content_block_stop', index: 0 },
{
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 15 },
},
{ type: 'message_stop' },
];

events.forEach((event, index) => {
setTimeout(() => {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
if (index === events.length - 1) {
res.end();
}
}, index * 10);
});
});

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

async function run() {
const server = await startMockAnthropicServer();

await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const client = new Anthropic({
apiKey: 'mock-api-key',
baseURL: `http://localhost:${server.address().port}/anthropic`,
});

const stream = client.messages.stream({
model: 'claude-3-haiku-20240307',
messages: [{ role: 'user', content: 'What is the capital of France?' }],
});

// Await completion so the deferred span ends before the parent span closes.
await new Promise((resolve, reject) => {
stream.on('end', resolve).on('error', reject);
});
});

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

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import {
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
GEN_AI_OPERATION_NAME_ATTRIBUTE,
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
GEN_AI_REQUEST_STREAM_ATTRIBUTE,
GEN_AI_RESPONSE_ID_ATTRIBUTE,
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
GEN_AI_SYSTEM_ATTRIBUTE,
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

// The origin distinguishes the orchestrion (diagnostics-channel) path from the
// OTel/proxy one (`auto.ai.anthropic`).
const ORCHESTRION_ORIGIN = 'auto.ai.orchestrion.anthropic';

describe('Anthropic integration (orchestrion)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, '../scenario.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => {
test('creates anthropic spans via the diagnostics-channel path', async () => {
await createRunner()
.ignore('event')
.expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
const completionSpan = container.items.find(
span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_mock123',
);
expect(completionSpan).toBeDefined();
expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(completionSpan!.status).toBe('ok');
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
type: 'string',
value: 'gen_ai.chat',
});
expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({
type: 'string',
value: 'chat',
});
expect(completionSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' });
expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({
type: 'string',
value: 'claude-3-haiku-20240307',
});
expect(completionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 10,
});
expect(completionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 15,
});
expect(completionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 25,
});
// Recording disabled: no inputs/outputs captured.
expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]).toBeUndefined();
expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined();

const errorSpan = container.items.find(span => span.name === 'chat error-model');
expect(errorSpan).toBeDefined();
expect(errorSpan!.status).not.toBe('ok');
expect(errorSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});

const tokenCountingSpan = container.items.find(
span =>
span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === 'gen_ai.chat' &&
span.status === 'ok' &&
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE] === undefined,
);
expect(tokenCountingSpan).toBeDefined();
expect(tokenCountingSpan!.name).toBe('chat claude-3-haiku-20240307');

const modelsSpan = container.items.find(span => span.name === 'models claude-3-haiku-20240307');
expect(modelsSpan).toBeDefined();
expect(modelsSpan!.status).toBe('ok');
expect(modelsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
type: 'string',
value: 'gen_ai.models',
});
expect(modelsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});

// messages.create({ stream: true }) — the async-iterable `Stream` (`stream: true` in the request).
const streamingCreateSpan = container.items.find(
span =>
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream123' &&
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
expect(streamingCreateSpan).toBeDefined();
expect(streamingCreateSpan!.status).toBe('ok');
expect(streamingCreateSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});
expect(streamingCreateSpan!.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toEqual({
type: 'boolean',
value: true,
});
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 10,
});
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 15,
});
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 25,
});
},
})
.start()
.completed();
});
});

createEsmAndCjsTests(__dirname, '../scenario.mjs', 'instrument-orchestrion-with-pii.mjs', (createRunner, test) => {
test('records inputs and outputs when PII is enabled', async () => {
await createRunner()
.ignore('event')
.expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
const completionSpan = container.items.find(
span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_mock123',
);
expect(completionSpan).toBeDefined();
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});
expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]).toEqual({
type: 'string',
value: '[{"role":"user","content":"What is the capital of France?"}]',
});
expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
type: 'string',
value: 'Hello from Anthropic mock!',
});

const streamingCreateSpan = container.items.find(
span =>
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream123' &&
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
expect(streamingCreateSpan).toBeDefined();
expect(streamingCreateSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
type: 'string',
value: 'Hello from stream!',
});
},
})
.start()
.completed();
});
});

// `messages.stream()` returns a `MessageStream` emitter; `scenario.mjs` fires it without awaiting
// completion, so a dedicated scenario awaits the `'end'` event to exercise the emitter span.
createEsmAndCjsTests(
__dirname,
'scenario-messages-stream.mjs',
'instrument-orchestrion-with-pii.mjs',
(createRunner, test) => {
test('creates a span for the messages.stream() emitter path', async () => {
await createRunner()
.ignore('event')
.expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
// The emitter span from `stream()` itself carries no `stream` request param, unlike the
// internal `messages.create({ stream: true })` the SDK issues under the hood.
const messageStreamSpan = container.items.find(
span =>
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream123' &&
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
);
expect(messageStreamSpan).toBeDefined();
expect(messageStreamSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(messageStreamSpan!.status).toBe('ok');
expect(messageStreamSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
type: 'string',
value: ORCHESTRION_ORIGIN,
});
expect(messageStreamSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
type: 'string',
value: 'gen_ai.chat',
});
expect(messageStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toEqual({
type: 'boolean',
value: true,
});
expect(messageStreamSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 10,
});
expect(messageStreamSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
type: 'integer',
value: 15,
});
expect(messageStreamSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
type: 'string',
value: 'Hello from stream!',
});
},
})
.start()
.completed();
});
},
);
});
11 changes: 9 additions & 2 deletions packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,23 @@ export * as metrics from './metrics/public-api';
export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors, getProviderMetadataAttributes } from './tracing/vercel-ai';
export { getTruncatedJsonString, shouldEnableTruncation } from './tracing/ai/utils';
export { getTruncatedJsonString, shouldEnableTruncation, resolveAIRecordingOptions } from './tracing/ai/utils';
export {
GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE,
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
} from './tracing/ai/gen-ai-attributes';
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
export { instrumentOpenAiClient } from './tracing/openai';
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
export {
instrumentAnthropicAiClient,
extractRequestAttributes as extractAnthropicRequestAttributes,
addPrivateRequestAttributes as addAnthropicRequestAttributes,
addResponseAttributes as addAnthropicResponseAttributes,
} from './tracing/anthropic-ai';
export { instrumentAsyncIterableStream, instrumentMessageStream } from './tracing/anthropic-ai/streaming';
export { ANTHROPIC_AI_INTEGRATION_NAME } from './tracing/anthropic-ai/constants';
export { instrumentGoogleGenAIClient } from './tracing/google-genai';
export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants';
Expand Down
Loading
Loading