Skip to content

Commit e3bdbed

Browse files
authored
ref(core): Introduce instrumented method registry for AI integrations (#19981)
Replace the shared `getOperationName()` function with per-provider method registries that map API paths to their operation name and streaming behavior. This explicitly couples the instrumented methods and necessary metadata in one place instead of having convoluted substring matching in multiple places that can be quite hard to reason about. Closes #19987 (added automatically)
1 parent 91709f0 commit e3bdbed

File tree

16 files changed

+134
-231
lines changed

16 files changed

+134
-231
lines changed

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

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ export interface AIRecordingOptions {
1717
recordOutputs?: boolean;
1818
}
1919

20+
/**
21+
* A method registry entry describes a single instrumented method:
22+
* which gen_ai operation it maps to and whether it is intrinsically streaming.
23+
*/
24+
export interface InstrumentedMethodEntry {
25+
/** Operation name (e.g. 'chat', 'embeddings', 'generate_content') */
26+
operation: string;
27+
/** True if the method itself is always streaming (not param-based) */
28+
streaming?: boolean;
29+
}
30+
31+
/**
32+
* Maps method paths to their registry entries.
33+
* Used by proxy-based AI client instrumentations to determine which methods
34+
* to instrument, what operation name to use, and whether they stream.
35+
*/
36+
export type InstrumentedMethodRegistry = Record<string, InstrumentedMethodEntry>;
37+
2038
/**
2139
* Resolves AI recording options by falling back to the client's `sendDefaultPii` setting.
2240
* Precedence: explicit option > sendDefaultPii > false
@@ -30,38 +48,6 @@ export function resolveAIRecordingOptions<T extends AIRecordingOptions>(options?
3048
} as T & Required<AIRecordingOptions>;
3149
}
3250

33-
/**
34-
* Maps AI method paths to OpenTelemetry semantic convention operation names
35-
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans
36-
*/
37-
export function getOperationName(methodPath: string): string {
38-
// OpenAI: chat.completions.create, responses.create, conversations.create
39-
// Anthropic: messages.create, messages.stream, completions.create
40-
// Google GenAI: chats.create, chat.sendMessage, chat.sendMessageStream
41-
if (
42-
methodPath.includes('completions') ||
43-
methodPath.includes('responses') ||
44-
methodPath.includes('conversations') ||
45-
methodPath.includes('messages') ||
46-
methodPath.includes('chat')
47-
) {
48-
return 'chat';
49-
}
50-
// OpenAI: embeddings.create
51-
if (methodPath.includes('embeddings')) {
52-
return 'embeddings';
53-
}
54-
// Google GenAI: models.generateContent, models.generateContentStream (must be before 'models' check)
55-
if (methodPath.includes('generateContent')) {
56-
return 'generate_content';
57-
}
58-
// Anthropic: models.get, models.retrieve (metadata retrieval only)
59-
if (methodPath.includes('models')) {
60-
return 'models';
61-
}
62-
return methodPath.split('.').pop() || 'unknown';
63-
}
64-
6551
/**
6652
* Build method path from current traversal
6753
*/
Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
import type { InstrumentedMethodRegistry } from '../ai/utils';
2+
13
export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI';
24

35
// https://docs.anthropic.com/en/api/messages
46
// https://docs.anthropic.com/en/api/models-list
5-
export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [
6-
'messages.create',
7-
'messages.stream',
8-
'messages.countTokens',
9-
'models.get',
10-
'completions.create',
11-
'models.retrieve',
12-
'beta.messages.create',
13-
] as const;
7+
export const ANTHROPIC_METHOD_REGISTRY = {
8+
'messages.create': { operation: 'chat' },
9+
'messages.stream': { operation: 'chat', streaming: true },
10+
'messages.countTokens': { operation: 'chat' },
11+
'models.get': { operation: 'models' },
12+
'completions.create': { operation: 'chat' },
13+
'models.retrieve': { operation: 'models' },
14+
'beta.messages.create': { operation: 'chat' },
15+
} as const satisfies InstrumentedMethodRegistry;

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

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,25 @@ import {
2121
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
2222
GEN_AI_SYSTEM_ATTRIBUTE,
2323
} from '../ai/gen-ai-attributes';
24+
import type { InstrumentedMethodEntry } from '../ai/utils';
2425
import {
2526
buildMethodPath,
26-
getOperationName,
2727
resolveAIRecordingOptions,
2828
setTokenUsageAttributes,
2929
wrapPromiseWithMethods,
3030
} from '../ai/utils';
31+
import { ANTHROPIC_METHOD_REGISTRY } from './constants';
3132
import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming';
32-
import type {
33-
AnthropicAiInstrumentedMethod,
34-
AnthropicAiOptions,
35-
AnthropicAiResponse,
36-
AnthropicAiStreamingEvent,
37-
ContentBlock,
38-
} from './types';
39-
import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils';
33+
import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types';
34+
import { handleResponseError, messagesFromParams, setMessagesAttribute } from './utils';
4035

4136
/**
4237
* Extract request attributes from method arguments
4338
*/
44-
function extractRequestAttributes(args: unknown[], methodPath: string): Record<string, unknown> {
39+
function extractRequestAttributes(args: unknown[], methodPath: string, operationName: string): Record<string, unknown> {
4540
const attributes: Record<string, unknown> = {
4641
[GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic',
47-
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath),
42+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName,
4843
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic',
4944
};
5045

@@ -263,19 +258,20 @@ function handleStreamingRequest<T extends unknown[], R>(
263258
*/
264259
function instrumentMethod<T extends unknown[], R>(
265260
originalMethod: (...args: T) => R | Promise<R>,
266-
methodPath: AnthropicAiInstrumentedMethod,
261+
methodPath: string,
262+
instrumentedMethod: InstrumentedMethodEntry,
267263
context: unknown,
268264
options: AnthropicAiOptions,
269265
): (...args: T) => R | Promise<R> {
270266
return new Proxy(originalMethod, {
271267
apply(target, thisArg, args: T): R | Promise<R> {
272-
const requestAttributes = extractRequestAttributes(args, methodPath);
268+
const operationName = instrumentedMethod.operation;
269+
const requestAttributes = extractRequestAttributes(args, methodPath, operationName);
273270
const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
274-
const operationName = getOperationName(methodPath);
275271

276272
const params = typeof args[0] === 'object' ? (args[0] as Record<string, unknown>) : undefined;
277273
const isStreamRequested = Boolean(params?.stream);
278-
const isStreamingMethod = methodPath === 'messages.stream';
274+
const isStreamingMethod = instrumentedMethod.streaming === true;
279275

280276
if (isStreamRequested || isStreamingMethod) {
281277
return handleStreamingRequest(
@@ -343,8 +339,15 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
343339
const value = (obj as Record<string, unknown>)[prop];
344340
const methodPath = buildMethodPath(currentPath, String(prop));
345341

346-
if (typeof value === 'function' && shouldInstrument(methodPath)) {
347-
return instrumentMethod(value as (...args: unknown[]) => unknown | Promise<unknown>, methodPath, obj, options);
342+
const instrumentedMethod = ANTHROPIC_METHOD_REGISTRY[methodPath as keyof typeof ANTHROPIC_METHOD_REGISTRY];
343+
if (typeof value === 'function' && instrumentedMethod) {
344+
return instrumentMethod(
345+
value as (...args: unknown[]) => unknown | Promise<unknown>,
346+
methodPath,
347+
instrumentedMethod,
348+
obj,
349+
options,
350+
);
348351
}
349352

350353
if (typeof value === 'function') {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants';
1+
import type { ANTHROPIC_METHOD_REGISTRY } from './constants';
22

33
export interface AnthropicAiOptions {
44
/**
@@ -84,7 +84,10 @@ export interface AnthropicAiIntegration {
8484
options: AnthropicAiOptions;
8585
}
8686

87-
export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number];
87+
/**
88+
* @deprecated This type is no longer used and will be removed in the next major version.
89+
*/
90+
export type AnthropicAiInstrumentedMethod = keyof typeof ANTHROPIC_METHOD_REGISTRY;
8891

8992
/**
9093
* Message type for Anthropic AI

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,7 @@ import {
88
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
99
} from '../ai/gen-ai-attributes';
1010
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
11-
import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants';
12-
import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types';
13-
14-
/**
15-
* Check if a method path should be instrumented
16-
*/
17-
export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod {
18-
return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod);
19-
}
11+
import type { AnthropicAiResponse } from './types';
2012

2113
/**
2214
* Set the messages and messages original length attributes.

packages/core/src/tracing/google-genai/constants.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import type { InstrumentedMethodRegistry } from '../ai/utils';
2+
13
export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI';
24

35
// https://ai.google.dev/api/rest/v1/models/generateContent
46
// https://ai.google.dev/api/rest/v1/chats/sendMessage
57
// https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream
68
// https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream
7-
export const GOOGLE_GENAI_INSTRUMENTED_METHODS = [
8-
'models.generateContent',
9-
'models.generateContentStream',
10-
'chats.create',
11-
'sendMessage',
12-
'sendMessageStream',
13-
] as const;
9+
export const GOOGLE_GENAI_METHOD_REGISTRY = {
10+
'models.generateContent': { operation: 'generate_content' },
11+
'models.generateContentStream': { operation: 'generate_content', streaming: true },
12+
'chats.create': { operation: 'chat' },
13+
// chat.* paths are built by createDeepProxy when it proxies the chat instance with CHAT_PATH as base
14+
'chat.sendMessage': { operation: 'chat' },
15+
'chat.sendMessageStream': { operation: 'chat', streaming: true },
16+
} as const satisfies InstrumentedMethodRegistry;
1417

1518
// Constants for internal use
1619
export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai';

packages/core/src/tracing/google-genai/index.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,13 @@ import {
2626
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
2727
} from '../ai/gen-ai-attributes';
2828
import { truncateGenAiMessages } from '../ai/messageTruncation';
29-
import { buildMethodPath, extractSystemInstructions, getOperationName, resolveAIRecordingOptions } from '../ai/utils';
30-
import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants';
29+
import type { InstrumentedMethodEntry } from '../ai/utils';
30+
import { buildMethodPath, extractSystemInstructions, resolveAIRecordingOptions } from '../ai/utils';
31+
import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants';
3132
import { instrumentStream } from './streaming';
32-
import type {
33-
Candidate,
34-
ContentPart,
35-
GoogleGenAIIstrumentedMethod,
36-
GoogleGenAIOptions,
37-
GoogleGenAIResponse,
38-
} from './types';
33+
import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types';
3934
import type { ContentListUnion, ContentUnion, Message, PartListUnion } from './utils';
40-
import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from './utils';
35+
import { contentUnionToMessages } from './utils';
4136

4237
/**
4338
* Extract model from parameters or chat context object
@@ -99,13 +94,13 @@ function extractConfigAttributes(config: Record<string, unknown>): Record<string
9994
* Builds the base attributes for span creation including system info, model, and config
10095
*/
10196
function extractRequestAttributes(
102-
methodPath: string,
97+
operationName: string,
10398
params?: Record<string, unknown>,
10499
context?: unknown,
105100
): Record<string, SpanAttributeValue> {
106101
const attributes: Record<string, SpanAttributeValue> = {
107102
[GEN_AI_SYSTEM_ATTRIBUTE]: GOOGLE_GENAI_SYSTEM_NAME,
108-
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath),
103+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName,
109104
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai',
110105
};
111106

@@ -251,21 +246,22 @@ function addResponseAttributes(span: Span, response: GoogleGenAIResponse, record
251246
*/
252247
function instrumentMethod<T extends unknown[], R>(
253248
originalMethod: (...args: T) => R | Promise<R>,
254-
methodPath: GoogleGenAIIstrumentedMethod,
249+
methodPath: string,
250+
instrumentedMethod: InstrumentedMethodEntry,
255251
context: unknown,
256252
options: GoogleGenAIOptions,
257253
): (...args: T) => R | Promise<R> {
258254
const isSyncCreate = methodPath === CHATS_CREATE_METHOD;
259255

260256
return new Proxy(originalMethod, {
261257
apply(target, _, args: T): R | Promise<R> {
258+
const operationName = instrumentedMethod.operation;
262259
const params = args[0] as Record<string, unknown> | undefined;
263-
const requestAttributes = extractRequestAttributes(methodPath, params, context);
260+
const requestAttributes = extractRequestAttributes(operationName, params, context);
264261
const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown';
265-
const operationName = getOperationName(methodPath);
266262

267263
// Check if this is a streaming method
268-
if (isStreamingMethod(methodPath)) {
264+
if (instrumentedMethod.streaming) {
269265
// Use startSpanManual for streaming methods to control span lifecycle
270266
return startSpanManual(
271267
{
@@ -338,12 +334,19 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
338334
const value = Reflect.get(t, prop, receiver);
339335
const methodPath = buildMethodPath(currentPath, String(prop));
340336

341-
if (typeof value === 'function' && shouldInstrument(methodPath)) {
337+
const instrumentedMethod = GOOGLE_GENAI_METHOD_REGISTRY[methodPath as keyof typeof GOOGLE_GENAI_METHOD_REGISTRY];
338+
if (typeof value === 'function' && instrumentedMethod) {
342339
// Special case: chats.create is synchronous but needs both instrumentation AND result proxying
343340
if (methodPath === CHATS_CREATE_METHOD) {
344-
const instrumentedMethod = instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, t, options);
341+
const wrappedMethod = instrumentMethod(
342+
value as (...args: unknown[]) => unknown,
343+
methodPath,
344+
instrumentedMethod,
345+
t,
346+
options,
347+
);
345348
return function instrumentedAndProxiedCreate(...args: unknown[]): unknown {
346-
const result = instrumentedMethod(...args);
349+
const result = wrappedMethod(...args);
347350
// If the result is an object (like a chat instance), proxy it too
348351
if (result && typeof result === 'object') {
349352
return createDeepProxy(result, CHAT_PATH, options);
@@ -352,7 +355,13 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
352355
};
353356
}
354357

355-
return instrumentMethod(value as (...args: unknown[]) => Promise<unknown>, methodPath, t, options);
358+
return instrumentMethod(
359+
value as (...args: unknown[]) => Promise<unknown>,
360+
methodPath,
361+
instrumentedMethod,
362+
t,
363+
options,
364+
);
356365
}
357366

358367
if (typeof value === 'function') {

packages/core/src/tracing/google-genai/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants';
1+
import type { GOOGLE_GENAI_METHOD_REGISTRY } from './constants';
22

33
export interface GoogleGenAIOptions {
44
/**
@@ -179,7 +179,10 @@ export interface GoogleGenAIChat {
179179
sendMessageStream: (...args: unknown[]) => Promise<AsyncGenerator<GenerateContentResponse, any, unknown>>;
180180
}
181181

182-
export type GoogleGenAIIstrumentedMethod = (typeof GOOGLE_GENAI_INSTRUMENTED_METHODS)[number];
182+
/**
183+
* @deprecated This type is no longer used and will be removed in the next major version.
184+
*/
185+
export type GoogleGenAIIstrumentedMethod = keyof typeof GOOGLE_GENAI_METHOD_REGISTRY;
183186

184187
// Export the response type for use in instrumentation
185188
export type GoogleGenAIResponse = GenerateContentResponse;

packages/core/src/tracing/google-genai/utils.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,3 @@
1-
import { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants';
2-
import type { GoogleGenAIIstrumentedMethod } from './types';
3-
4-
/**
5-
* Check if a method path should be instrumented
6-
*/
7-
export function shouldInstrument(methodPath: string): methodPath is GoogleGenAIIstrumentedMethod {
8-
// Check for exact matches first (like 'models.generateContent')
9-
if (GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodPath as GoogleGenAIIstrumentedMethod)) {
10-
return true;
11-
}
12-
13-
// Check for method name matches (like 'sendMessage' from chat instances)
14-
const methodName = methodPath.split('.').pop();
15-
return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod);
16-
}
17-
18-
/**
19-
* Check if a method is a streaming method
20-
*/
21-
export function isStreamingMethod(methodPath: string): boolean {
22-
return methodPath.includes('Stream');
23-
}
24-
251
// Copied from https://googleapis.github.io/js-genai/release_docs/index.html
262
export type ContentListUnion = Content | Content[] | PartListUnion;
273
export type ContentUnion = Content | PartUnion[] | PartUnion;

0 commit comments

Comments
 (0)