Skip to content

Commit c159dc0

Browse files
committed
feat(node): Add Anthropic AI integration
1 parent 08fb932 commit c159dc0

File tree

15 files changed

+589
-4
lines changed

15 files changed

+589
-4
lines changed

dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-error-test/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { generateText } from 'ai';
1+
import ai from 'ai';
2+
ai.generateText
23
import { MockLanguageModelV1 } from 'ai/test';
34
import { z } from 'zod';
45
import * as Sentry from '@sentry/nextjs';

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ export { consoleLoggingIntegration } from './logs/console-integration';
120120
export { addVercelAiProcessors } from './utils/vercel-ai';
121121
export { instrumentOpenAiClient } from './utils/openai';
122122
export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants';
123+
export { instrumentAnthropicAiClient } from './utils/anthropic-ai';
124+
export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants';
123125
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types';
126+
export type { AnthropicAiClient, AnthropicAiOptions, AnthropicAiInstrumentedMethod } from './utils/anthropic-ai/types';
124127
export type { FeatureFlag } from './utils/featureFlags';
125128

126129
export {

packages/core/src/utils/gen-ai-attributes.ts renamed to packages/core/src/utils/ai/gen-ai-attributes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
// OPENTELEMETRY SEMANTIC CONVENTIONS FOR GENAI
99
// =============================================================================
1010

11+
/**
12+
* The input messages sent to the model
13+
*/
14+
export const GEN_AI_PROMPT_ATTRIBUTE = 'gen_ai.prompt';
15+
1116
/**
1217
* The Generative AI system being used
1318
* For OpenAI, this should always be "openai"
@@ -164,3 +169,12 @@ export const OPENAI_OPERATIONS = {
164169
CHAT: 'chat',
165170
RESPONSES: 'responses',
166171
} as const;
172+
173+
// =============================================================================
174+
// ANTHROPIC AI OPERATIONS
175+
// =============================================================================
176+
177+
/**
178+
* The response timestamp from Anthropic AI (ISO string)
179+
*/
180+
export const ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE = 'anthropic.response.timestamp';

packages/core/src/utils/ai/utils.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.)
3+
*/
4+
import type { Span } from '../../types-hoist/span';
5+
import {
6+
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
7+
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
8+
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
9+
} from './gen-ai-attributes';
10+
/**
11+
* Maps AI method paths to Sentry operation name
12+
*/
13+
export function getFinalOperationName(methodPath: string): string {
14+
return `gen_ai.${methodPath.split('.').pop() || 'unknown'}`;
15+
}
16+
17+
/**
18+
* Get the span operation for AI methods
19+
* Following Sentry's convention: "gen_ai.{operation_name}"
20+
*/
21+
export function getSpanOperation(methodPath: string): string {
22+
return `gen_ai.${getFinalOperationName(methodPath)}`;
23+
}
24+
25+
/**
26+
* Build method path from current traversal
27+
*/
28+
export function buildMethodPath(currentPath: string, prop: string): string {
29+
return currentPath ? `${currentPath}.${prop}` : prop;
30+
}
31+
32+
/**
33+
* Set token usage attributes
34+
* @param span - The span to add attributes to
35+
* @param promptTokens - The number of prompt tokens
36+
* @param completionTokens - The number of completion tokens
37+
* @param cachedInputTokens - The number of cached input tokens
38+
* @param cachedOutputTokens - The number of cached output tokens
39+
*/
40+
export function setTokenUsageAttributes(
41+
span: Span,
42+
promptTokens?: number,
43+
completionTokens?: number,
44+
cachedInputTokens?: number,
45+
cachedOutputTokens?: number,
46+
): void {
47+
if (promptTokens !== undefined) {
48+
span.setAttributes({
49+
[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens,
50+
});
51+
}
52+
if (completionTokens !== undefined) {
53+
span.setAttributes({
54+
[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens,
55+
});
56+
}
57+
if (
58+
promptTokens !== undefined ||
59+
completionTokens !== undefined ||
60+
cachedInputTokens !== undefined ||
61+
cachedOutputTokens !== undefined
62+
) {
63+
/**
64+
* Total input tokens in a request is the summation of `input_tokens`,
65+
* `cache_creation_input_tokens`, and `cache_read_input_tokens`.
66+
*/
67+
const totalTokens =
68+
(promptTokens ?? 0) + (completionTokens ?? 0) + (cachedInputTokens ?? 0) + (cachedOutputTokens ?? 0);
69+
70+
span.setAttributes({
71+
[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens,
72+
});
73+
}
74+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI';
2+
3+
// https://docs.anthropic.com/en/api/messages
4+
// https://docs.anthropic.com/en/api/models-list
5+
export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [
6+
'anthropic.messages.create',
7+
'anthropic.messages.countTokens',
8+
'anthropic.models.list',
9+
'anthropic.models.get',
10+
'anthropic.completions.create',
11+
] as const;
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { getCurrentScope } from '../../currentScopes';
2+
import { captureException } from '../../exports';
3+
import { startSpan } from '../../tracing/trace';
4+
import type { Span, SpanAttributeValue } from '../../types-hoist/span';
5+
import {
6+
ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE,
7+
GEN_AI_OPERATION_NAME_ATTRIBUTE,
8+
GEN_AI_PROMPT_ATTRIBUTE,
9+
GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE,
10+
GEN_AI_REQUEST_MESSAGES_ATTRIBUTE,
11+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
12+
GEN_AI_REQUEST_STREAM_ATTRIBUTE,
13+
GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE,
14+
GEN_AI_REQUEST_TOP_K_ATTRIBUTE,
15+
GEN_AI_REQUEST_TOP_P_ATTRIBUTE,
16+
GEN_AI_RESPONSE_ID_ATTRIBUTE,
17+
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
18+
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
19+
GEN_AI_SYSTEM_ATTRIBUTE,
20+
} from '../ai/gen-ai-attributes';
21+
import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils';
22+
import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants';
23+
import type {
24+
AnthropicAiClient,
25+
AnthropicAiInstrumentedMethod,
26+
AnthropicAiIntegration,
27+
AnthropicAiOptions,
28+
AnthropicAiResponse,
29+
} from './types';
30+
import { shouldInstrument } from './utils';
31+
32+
/**
33+
* Extract request attributes from method arguments
34+
*/
35+
function extractRequestAttributes(args: unknown[], methodPath: string): Record<string, unknown> {
36+
const attributes: Record<string, unknown> = {
37+
[GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic',
38+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath),
39+
};
40+
41+
if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) {
42+
const params = args[0] as Record<string, unknown>;
43+
44+
attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown';
45+
if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature;
46+
if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p;
47+
if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream;
48+
if ('top_k' in params) attributes[GEN_AI_REQUEST_TOP_K_ATTRIBUTE] = params.top_k;
49+
attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty;
50+
} else {
51+
attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown';
52+
}
53+
54+
return attributes;
55+
}
56+
57+
/**
58+
* Add private request attributes to spans.
59+
* This is only recorded if recordInputs is true.
60+
*/
61+
function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>): void {
62+
if ('messages' in params) {
63+
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) });
64+
}
65+
if ('input' in params) {
66+
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) });
67+
}
68+
if ('prompt' in params) {
69+
span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) });
70+
}
71+
}
72+
73+
/**
74+
* Add response attributes to spans
75+
*/
76+
function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void {
77+
if (!response || typeof response !== 'object') return;
78+
79+
// Private response attributes that are only recorded if recordOutputs is true.
80+
if (recordOutputs) {
81+
// Messages.create
82+
if ('content' in response) {
83+
span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content });
84+
}
85+
// Completions.create
86+
if ('completion' in response) {
87+
span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.completion });
88+
}
89+
// Models.countTokens
90+
if ('input_tokens' in response) {
91+
span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(response.input_tokens) });
92+
}
93+
}
94+
95+
span.setAttributes({
96+
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: response.id,
97+
});
98+
span.setAttributes({
99+
[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model,
100+
});
101+
span.setAttributes({
102+
[ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created * 1000).toISOString(),
103+
});
104+
105+
if (response.usage) {
106+
setTokenUsageAttributes(
107+
span,
108+
response.usage.input_tokens,
109+
response.usage.output_tokens,
110+
response.usage.cache_creation_input_tokens,
111+
response.usage.cache_read_input_tokens,
112+
);
113+
}
114+
}
115+
116+
/**
117+
* Get record options from the integration
118+
*/
119+
function getOptionsFromIntegration(): AnthropicAiOptions {
120+
const scope = getCurrentScope();
121+
const client = scope.getClient();
122+
const integration = client?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME) as AnthropicAiIntegration | undefined;
123+
const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false;
124+
125+
return {
126+
recordInputs: integration?.options?.recordInputs ?? shouldRecordInputsAndOutputs,
127+
recordOutputs: integration?.options?.recordOutputs ?? shouldRecordInputsAndOutputs,
128+
};
129+
}
130+
131+
/**
132+
* Instrument a method with Sentry spans
133+
* Following Sentry AI Agents Manual Instrumentation conventions
134+
* @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation
135+
*/
136+
function instrumentMethod<T extends unknown[], R>(
137+
originalMethod: (...args: T) => Promise<R>,
138+
methodPath: AnthropicAiInstrumentedMethod,
139+
context: unknown,
140+
options?: AnthropicAiOptions,
141+
): (...args: T) => Promise<R> {
142+
return async function instrumentedMethod(...args: T): Promise<R> {
143+
const finalOptions = options || getOptionsFromIntegration();
144+
const requestAttributes = extractRequestAttributes(args, methodPath);
145+
const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
146+
const operationName = getFinalOperationName(methodPath);
147+
148+
// TODO: Handle streaming responses
149+
return startSpan(
150+
{
151+
name: `${operationName} ${model}`,
152+
op: getSpanOperation(methodPath),
153+
attributes: requestAttributes as Record<string, SpanAttributeValue>,
154+
},
155+
async (span: Span) => {
156+
try {
157+
if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') {
158+
addPrivateRequestAttributes(span, args[0] as Record<string, unknown>);
159+
}
160+
161+
const result = await originalMethod.apply(context, args);
162+
addResponseAttributes(span, result, finalOptions.recordOutputs);
163+
return result;
164+
} catch (error) {
165+
captureException(error);
166+
throw error;
167+
}
168+
},
169+
);
170+
};
171+
}
172+
173+
/**
174+
* Create a deep proxy for Anthropic AI client instrumentation
175+
*/
176+
function createDeepProxy<T extends AnthropicAiClient>(target: T, currentPath = '', options?: AnthropicAiOptions): T {
177+
return new Proxy(target, {
178+
get(obj: object, prop: string): unknown {
179+
const value = (obj as Record<string, unknown>)[prop];
180+
const methodPath = buildMethodPath(currentPath, String(prop));
181+
// eslint-disable-next-line no-console
182+
console.log('value ----->>>>', value);
183+
184+
if (typeof value === 'function' && shouldInstrument(methodPath)) {
185+
return instrumentMethod(value as (...args: unknown[]) => Promise<unknown>, methodPath, obj, options);
186+
}
187+
188+
if (typeof value === 'function') {
189+
// Bind non-instrumented functions to preserve the original `this` context,
190+
// which is required for accessing private class fields (e.g. #baseURL) in OpenAI SDK v5.
191+
return value.bind(obj);
192+
}
193+
194+
if (value && typeof value === 'object') {
195+
return createDeepProxy(value as object, methodPath, options);
196+
}
197+
198+
return value;
199+
},
200+
}) as T;
201+
}
202+
203+
/**
204+
* Instrument an Anthropic AI client with Sentry tracing
205+
* Can be used across Node.js, Cloudflare Workers, and Vercel Edge
206+
*
207+
* @template T - The type of the client that extends AnthropicAiClient
208+
* @param client - The Anthropic AI client to instrument
209+
* @param options - Optional configuration for recording inputs and outputs
210+
* @returns The instrumented client with the same type as the input
211+
*/
212+
export function instrumentAnthropicAiClient<T extends AnthropicAiClient>(client: T, options?: AnthropicAiOptions): T {
213+
return createDeepProxy(client, '', options);
214+
}

0 commit comments

Comments
 (0)