Skip to content

Commit bea02b1

Browse files
nicohrubecclaude
andcommitted
feat(server-utils): Migrate Anthropic integration to orchestrion
Adds an orchestrion based Anthropic integration to server-utils, covering all the APIs (messages, completions, models, beta messages) and both streaming and non-streaming mode. Leaves the core integration intact and only exports the necessary utils that are needed for the orchestrion integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e4dc3e2 commit bea02b1

10 files changed

Lines changed: 506 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.experimentalUseDiagnosticsChannelInjection();
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
release: '1.0',
9+
tracesSampleRate: 1.0,
10+
dataCollection: { genAI: { inputs: true, outputs: true } },
11+
transport: loggingTransport,
12+
beforeSendTransaction: event => {
13+
if (event.transaction.includes('/anthropic/v1/')) {
14+
return null;
15+
}
16+
return event;
17+
},
18+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.experimentalUseDiagnosticsChannelInjection();
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
release: '1.0',
9+
tracesSampleRate: 1.0,
10+
dataCollection: { genAI: { inputs: false, outputs: false } },
11+
transport: loggingTransport,
12+
beforeSendTransaction: event => {
13+
if (event.transaction.includes('/anthropic/v1/')) {
14+
return null;
15+
}
16+
return event;
17+
},
18+
});
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
2+
import { afterAll, describe, expect } from 'vitest';
3+
import {
4+
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
5+
GEN_AI_OPERATION_NAME_ATTRIBUTE,
6+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
7+
GEN_AI_REQUEST_STREAM_ATTRIBUTE,
8+
GEN_AI_RESPONSE_ID_ATTRIBUTE,
9+
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
10+
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
11+
GEN_AI_SYSTEM_ATTRIBUTE,
12+
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
13+
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
14+
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
15+
} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes';
16+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
17+
18+
// The origin distinguishes the orchestrion (diagnostics-channel) path from the
19+
// OTel/proxy one (`auto.ai.anthropic`).
20+
const ORCHESTRION_ORIGIN = 'auto.ai.orchestrion.anthropic';
21+
22+
describe('Anthropic integration (orchestrion)', () => {
23+
afterAll(() => {
24+
cleanupChildProcesses();
25+
});
26+
27+
createEsmAndCjsTests(__dirname, '../scenario.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => {
28+
test('creates anthropic spans via the diagnostics-channel path', async () => {
29+
await createRunner()
30+
.ignore('event')
31+
.expect({ transaction: { transaction: 'main' } })
32+
.expect({
33+
span: container => {
34+
const completionSpan = container.items.find(
35+
span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_mock123',
36+
);
37+
expect(completionSpan).toBeDefined();
38+
expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
39+
expect(completionSpan!.status).toBe('ok');
40+
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
41+
type: 'string',
42+
value: ORCHESTRION_ORIGIN,
43+
});
44+
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
45+
type: 'string',
46+
value: 'gen_ai.chat',
47+
});
48+
expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({
49+
type: 'string',
50+
value: 'chat',
51+
});
52+
expect(completionSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' });
53+
expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({
54+
type: 'string',
55+
value: 'claude-3-haiku-20240307',
56+
});
57+
expect(completionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
58+
type: 'integer',
59+
value: 10,
60+
});
61+
expect(completionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
62+
type: 'integer',
63+
value: 15,
64+
});
65+
expect(completionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({
66+
type: 'integer',
67+
value: 25,
68+
});
69+
// Recording disabled: no inputs/outputs captured.
70+
expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]).toBeUndefined();
71+
expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined();
72+
73+
const errorSpan = container.items.find(span => span.name === 'chat error-model');
74+
expect(errorSpan).toBeDefined();
75+
expect(errorSpan!.status).not.toBe('ok');
76+
expect(errorSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
77+
type: 'string',
78+
value: ORCHESTRION_ORIGIN,
79+
});
80+
81+
const tokenCountingSpan = container.items.find(
82+
span =>
83+
span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === 'gen_ai.chat' &&
84+
span.status === 'ok' &&
85+
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE] === undefined,
86+
);
87+
expect(tokenCountingSpan).toBeDefined();
88+
expect(tokenCountingSpan!.name).toBe('chat claude-3-haiku-20240307');
89+
90+
const modelsSpan = container.items.find(span => span.name === 'models claude-3-haiku-20240307');
91+
expect(modelsSpan).toBeDefined();
92+
expect(modelsSpan!.status).toBe('ok');
93+
expect(modelsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
94+
type: 'string',
95+
value: 'gen_ai.models',
96+
});
97+
expect(modelsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
98+
type: 'string',
99+
value: ORCHESTRION_ORIGIN,
100+
});
101+
102+
// messages.create({ stream: true }) — the async-iterable `Stream` (`stream: true` in the request).
103+
const streamingCreateSpan = container.items.find(
104+
span =>
105+
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream123' &&
106+
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
107+
);
108+
expect(streamingCreateSpan).toBeDefined();
109+
expect(streamingCreateSpan!.status).toBe('ok');
110+
expect(streamingCreateSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
111+
type: 'string',
112+
value: ORCHESTRION_ORIGIN,
113+
});
114+
expect(streamingCreateSpan!.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toEqual({
115+
type: 'boolean',
116+
value: true,
117+
});
118+
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
119+
type: 'integer',
120+
value: 10,
121+
});
122+
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
123+
type: 'integer',
124+
value: 15,
125+
});
126+
expect(streamingCreateSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({
127+
type: 'integer',
128+
value: 25,
129+
});
130+
},
131+
})
132+
.start()
133+
.completed();
134+
});
135+
});
136+
137+
createEsmAndCjsTests(__dirname, '../scenario.mjs', 'instrument-orchestrion-with-pii.mjs', (createRunner, test) => {
138+
test('records inputs and outputs when PII is enabled', async () => {
139+
await createRunner()
140+
.ignore('event')
141+
.expect({ transaction: { transaction: 'main' } })
142+
.expect({
143+
span: container => {
144+
const completionSpan = container.items.find(
145+
span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_mock123',
146+
);
147+
expect(completionSpan).toBeDefined();
148+
expect(completionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
149+
type: 'string',
150+
value: ORCHESTRION_ORIGIN,
151+
});
152+
expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]).toEqual({
153+
type: 'string',
154+
value: '[{"role":"user","content":"What is the capital of France?"}]',
155+
});
156+
expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
157+
type: 'string',
158+
value: 'Hello from Anthropic mock!',
159+
});
160+
161+
const streamingCreateSpan = container.items.find(
162+
span =>
163+
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream123' &&
164+
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
165+
);
166+
expect(streamingCreateSpan).toBeDefined();
167+
expect(streamingCreateSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
168+
type: 'string',
169+
value: 'Hello from stream!',
170+
});
171+
},
172+
})
173+
.start()
174+
.completed();
175+
});
176+
});
177+
178+
createEsmAndCjsTests(
179+
__dirname,
180+
'../scenario-stream.mjs',
181+
'instrument-orchestrion-with-pii.mjs',
182+
(createRunner, test) => {
183+
test('creates a span for the messages.stream() emitter path', async () => {
184+
await createRunner()
185+
.ignore('event')
186+
.expect({ transaction: { transaction: 'main' } })
187+
.expect({
188+
span: container => {
189+
// The emitter span from `stream()` itself carries no `stream` request param, unlike
190+
// `messages.create({ stream: true })`.
191+
const messageStreamSpan = container.items.find(
192+
span =>
193+
span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'msg_stream_1' &&
194+
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
195+
);
196+
expect(messageStreamSpan).toBeDefined();
197+
expect(messageStreamSpan!.name).toBe('chat claude-3-haiku-20240307');
198+
expect(messageStreamSpan!.status).toBe('ok');
199+
expect(messageStreamSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({
200+
type: 'string',
201+
value: ORCHESTRION_ORIGIN,
202+
});
203+
expect(messageStreamSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({
204+
type: 'string',
205+
value: 'gen_ai.chat',
206+
});
207+
expect(messageStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toEqual({
208+
type: 'boolean',
209+
value: true,
210+
});
211+
expect(messageStreamSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({
212+
type: 'integer',
213+
value: 10,
214+
});
215+
expect(messageStreamSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({
216+
type: 'integer',
217+
value: 15,
218+
});
219+
expect(messageStreamSpan!.attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toEqual({
220+
type: 'string',
221+
value: 'Hello from stream!',
222+
});
223+
},
224+
})
225+
.start()
226+
.completed();
227+
});
228+
},
229+
);
230+
});

packages/core/src/shared-exports.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,16 +177,23 @@ export * as metrics from './metrics/public-api';
177177
export type { MetricOptions } from './metrics/public-api';
178178
export { createConsolaReporter } from './integrations/consola';
179179
export { addVercelAiProcessors, getProviderMetadataAttributes } from './tracing/vercel-ai';
180-
export { getTruncatedJsonString, shouldEnableTruncation } from './tracing/ai/utils';
180+
export { getTruncatedJsonString, shouldEnableTruncation, resolveAIRecordingOptions } from './tracing/ai/utils';
181181
export {
182182
GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE,
183+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
183184
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
184185
} from './tracing/ai/gen-ai-attributes';
185186
export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils';
186187
export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants';
187188
export { instrumentOpenAiClient } from './tracing/openai';
188189
export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants';
189-
export { instrumentAnthropicAiClient } from './tracing/anthropic-ai';
190+
export {
191+
instrumentAnthropicAiClient,
192+
extractRequestAttributes as extractAnthropicRequestAttributes,
193+
addPrivateRequestAttributes as addAnthropicRequestAttributes,
194+
addResponseAttributes as addAnthropicResponseAttributes,
195+
} from './tracing/anthropic-ai';
196+
export { instrumentAsyncIterableStream, instrumentMessageStream } from './tracing/anthropic-ai/streaming';
190197
export { ANTHROPIC_AI_INTEGRATION_NAME } from './tracing/anthropic-ai/constants';
191198
export { instrumentGoogleGenAIClient } from './tracing/google-genai';
192199
export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants';

packages/core/src/tracing/anthropic-ai/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ import { handleResponseError, messagesFromParams, setMessagesAttribute } from '.
3636
/**
3737
* Extract request attributes from method arguments
3838
*/
39-
function extractRequestAttributes(args: unknown[], methodPath: string, operationName: string): Record<string, unknown> {
39+
export function extractRequestAttributes(
40+
args: unknown[],
41+
methodPath: string,
42+
operationName: string,
43+
): Record<string, unknown> {
4044
const attributes: Record<string, unknown> = {
4145
[GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic',
4246
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName,
@@ -73,7 +77,11 @@ function extractRequestAttributes(args: unknown[], methodPath: string, operation
7377
* Add private request attributes to spans.
7478
* This is only recorded if recordInputs is true.
7579
*/
76-
function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>, enableTruncation: boolean): void {
80+
export function addPrivateRequestAttributes(
81+
span: Span,
82+
params: Record<string, unknown>,
83+
enableTruncation: boolean,
84+
): void {
7785
const messages = messagesFromParams(params);
7886
setMessagesAttribute(span, messages, enableTruncation);
7987

@@ -143,7 +151,7 @@ function addMetadataAttributes(span: Span, response: AnthropicAiResponse): void
143151
/**
144152
* Add response attributes to spans
145153
*/
146-
function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void {
154+
export function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void {
147155
if (!response || typeof response !== 'object') return;
148156

149157
// capture error, do not add attributes if error (they shouldn't exist)

packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
mysqlChannelIntegration,
33
lruMemoizerChannelIntegration,
4+
anthropicChannelIntegration,
45
detectOrchestrionSetup,
56
} from '@sentry/server-utils/orchestrion';
67
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
@@ -41,7 +42,11 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject
4142
*/
4243
export function experimentalUseDiagnosticsChannelInjection(): void {
4344
setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => {
44-
const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const;
45+
const integrations = [
46+
mysqlChannelIntegration(),
47+
lruMemoizerChannelIntegration(),
48+
anthropicChannelIntegration(),
49+
] as const;
4550
const replacedOtelIntegrationNames = integrations.map(i => i.name);
4651

4752
return {

0 commit comments

Comments
 (0)