-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathutils.ts
More file actions
331 lines (298 loc) · 11.9 KB
/
utils.ts
File metadata and controls
331 lines (298 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
/**
* Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.)
*/
import { captureException } from '../../exports';
import { getClient } from '../../currentScopes';
import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled';
import type { Span } from '../../types-hoist/span';
import { isThenable } from '../../utils/is';
import {
GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE,
GEN_AI_RESPONSE_ID_ATTRIBUTE,
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from './gen-ai-attributes';
import { truncateGenAiMessages, truncateGenAiStringInput } from './messageTruncation';
export interface AIRecordingOptions {
recordInputs?: boolean;
recordOutputs?: boolean;
}
/**
* A method registry entry describes a single instrumented method:
* which gen_ai operation it maps to and whether it is intrinsically streaming.
*/
export interface InstrumentedMethodEntry {
/** Operation name (e.g. 'chat', 'embeddings', 'generate_content'). Omit for factory methods that only need result proxying. */
operation?: string;
/** True if the method itself is always streaming (not param-based) */
streaming?: boolean;
/** When set, the method's return value is re-proxied with this as the base path */
proxyResultPath?: string;
}
/**
* Maps method paths to their registry entries.
* Used by proxy-based AI client instrumentations to determine which methods
* to instrument, what operation name to use, and whether they stream.
*/
export type InstrumentedMethodRegistry = Record<string, InstrumentedMethodEntry>;
/**
* Resolves AI recording options by falling back to the client's `sendDefaultPii` setting.
* Precedence: explicit option > sendDefaultPii > false
*/
export function resolveAIRecordingOptions<T extends AIRecordingOptions>(options?: T): T & Required<AIRecordingOptions> {
const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii);
return {
...options,
recordInputs: options?.recordInputs ?? sendDefaultPii,
recordOutputs: options?.recordOutputs ?? sendDefaultPii,
} as T & Required<AIRecordingOptions>;
}
/**
* Resolves whether truncation should be enabled.
* If the user explicitly set `enableTruncation`, that value is used.
* Otherwise, truncation is disabled when span streaming is active.
*/
export function shouldEnableTruncation(enableTruncation: boolean | undefined): boolean {
const client = getClient();
return enableTruncation ?? !(client && hasSpanStreamingEnabled(client));
}
/**
* Build method path from current traversal
*/
export function buildMethodPath(currentPath: string, prop: string): string {
return currentPath ? `${currentPath}.${prop}` : prop;
}
/**
* Set token usage attributes
* @param span - The span to add attributes to
* @param promptTokens - The number of prompt tokens
* @param completionTokens - The number of completion tokens
* @param cachedInputTokens - The number of cached input tokens
* @param cachedOutputTokens - The number of cached output tokens
*/
export function setTokenUsageAttributes(
span: Span,
promptTokens?: number,
completionTokens?: number,
cachedInputTokens?: number,
cachedOutputTokens?: number,
): void {
if (promptTokens !== undefined) {
span.setAttributes({
[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens,
});
}
if (completionTokens !== undefined) {
span.setAttributes({
[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens,
});
}
if (
promptTokens !== undefined ||
completionTokens !== undefined ||
cachedInputTokens !== undefined ||
cachedOutputTokens !== undefined
) {
/**
* Total input tokens in a request is the summation of `input_tokens`,
* `cache_creation_input_tokens`, and `cache_read_input_tokens`.
*/
const totalTokens =
(promptTokens ?? 0) + (completionTokens ?? 0) + (cachedInputTokens ?? 0) + (cachedOutputTokens ?? 0);
span.setAttributes({
[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens,
});
}
}
export interface StreamResponseState {
responseId?: string;
responseModel?: string;
finishReasons: string[];
responseTexts: string[];
toolCalls: unknown[];
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cacheCreationInputTokens?: number;
cacheReadInputTokens?: number;
}
/**
* Ends a streaming span by setting all accumulated response attributes and ending the span.
* Shared across OpenAI, Anthropic, and Google GenAI streaming implementations.
*/
export function endStreamSpan(span: Span, state: StreamResponseState, recordOutputs: boolean): void {
if (!span.isRecording()) {
return;
}
const attrs: Record<string, string | number | boolean> = {
[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true,
};
if (state.responseId) attrs[GEN_AI_RESPONSE_ID_ATTRIBUTE] = state.responseId;
if (state.responseModel) attrs[GEN_AI_RESPONSE_MODEL_ATTRIBUTE] = state.responseModel;
if (state.promptTokens !== undefined) attrs[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = state.promptTokens;
if (state.completionTokens !== undefined) attrs[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = state.completionTokens;
// Use explicit total if provided (OpenAI, Google), otherwise compute from cache tokens (Anthropic)
if (state.totalTokens !== undefined) {
attrs[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE] = state.totalTokens;
} else if (
state.promptTokens !== undefined ||
state.completionTokens !== undefined ||
state.cacheCreationInputTokens !== undefined ||
state.cacheReadInputTokens !== undefined
) {
attrs[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE] =
(state.promptTokens ?? 0) +
(state.completionTokens ?? 0) +
(state.cacheCreationInputTokens ?? 0) +
(state.cacheReadInputTokens ?? 0);
}
if (state.finishReasons.length) {
attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify(state.finishReasons);
}
if (recordOutputs && state.responseTexts.length) {
attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = state.responseTexts.join('');
}
if (recordOutputs && state.toolCalls.length) {
attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(state.toolCalls);
}
span.setAttributes(attrs);
span.end();
}
/**
* Serialize a value to a JSON string without truncation.
* Strings are returned as-is, arrays and objects are JSON-stringified.
*/
export function getJsonString<T>(value: T | T[]): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
/**
* Get the truncated JSON string for a string or array of strings.
*
* @param value - The string or array of strings to truncate
* @returns The truncated JSON string
*/
export function getTruncatedJsonString<T>(value: T | T[]): string {
if (typeof value === 'string') {
// Some values are already JSON strings, so we don't need to duplicate the JSON parsing
return truncateGenAiStringInput(value);
}
if (Array.isArray(value)) {
// truncateGenAiMessages returns an array of strings, so we need to stringify it
const truncatedMessages = truncateGenAiMessages(value);
return JSON.stringify(truncatedMessages);
}
// value is an object, so we need to stringify it
return JSON.stringify(value);
}
/**
* Extract system instructions from messages array.
* Finds the first system message and formats it according to OpenTelemetry semantic conventions.
*
* @param messages - Array of messages to extract system instructions from
* @returns systemInstructions (JSON string) and filteredMessages (without system message)
*/
export function extractSystemInstructions(messages: unknown[] | unknown): {
systemInstructions: string | undefined;
filteredMessages: unknown[] | unknown;
} {
if (!Array.isArray(messages)) {
return { systemInstructions: undefined, filteredMessages: messages };
}
const systemMessageIndex = messages.findIndex(
msg => msg && typeof msg === 'object' && 'role' in msg && (msg as { role: string }).role === 'system',
);
if (systemMessageIndex === -1) {
return { systemInstructions: undefined, filteredMessages: messages };
}
const systemMessage = messages[systemMessageIndex] as { role: string; content?: string | unknown };
const systemContent =
typeof systemMessage.content === 'string'
? systemMessage.content
: systemMessage.content !== undefined
? JSON.stringify(systemMessage.content)
: undefined;
if (!systemContent) {
return { systemInstructions: undefined, filteredMessages: messages };
}
const systemInstructions = JSON.stringify([{ type: 'text', content: systemContent }]);
const filteredMessages = [...messages.slice(0, systemMessageIndex), ...messages.slice(systemMessageIndex + 1)];
return { systemInstructions, filteredMessages };
}
/**
* Creates a wrapped version of .withResponse() that replaces the data field
* with the instrumented result while preserving metadata (response, request_id).
*/
async function createWithResponseWrapper<T>(
originalWithResponse: Promise<unknown>,
instrumentedPromise: Promise<T>,
mechanismType: string,
): Promise<unknown> {
// Attach catch handler to originalWithResponse immediately to prevent unhandled rejection
// If instrumentedPromise rejects first, we still need this handled
const safeOriginalWithResponse = originalWithResponse.catch(error => {
captureException(error, {
mechanism: {
handled: false,
type: mechanismType,
},
});
throw error;
});
const instrumentedResult = await instrumentedPromise;
const originalWrapper = await safeOriginalWithResponse;
// Combine instrumented result with original metadata
if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) {
return {
...originalWrapper,
data: instrumentedResult,
};
}
return instrumentedResult;
}
/**
* Wraps a promise-like object to preserve additional methods (like .withResponse())
* that AI SDK clients (OpenAI, Anthropic) attach to their APIPromise return values.
*
* Standard Promise methods (.then, .catch, .finally) are routed to the instrumented
* promise to preserve Sentry's span instrumentation, while custom SDK methods are
* forwarded to the original promise to maintain the SDK's API surface.
*/
export function wrapPromiseWithMethods<R>(
originalPromiseLike: Promise<R>,
instrumentedPromise: Promise<R>,
mechanismType: string,
): Promise<R> {
// If the original result is not thenable, return the instrumented promise
if (!isThenable(originalPromiseLike)) {
return instrumentedPromise;
}
// Create a proxy that forwards Promise methods to instrumentedPromise
// and preserves additional methods from the original result
return new Proxy(originalPromiseLike, {
get(target: object, prop: string | symbol): unknown {
// For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag),
// use instrumentedPromise to preserve Sentry instrumentation.
// For custom methods (like .withResponse()), use the original target.
const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag;
const source = useInstrumentedPromise ? instrumentedPromise : target;
const value = Reflect.get(source, prop) as unknown;
// Special handling for .withResponse() to preserve instrumentation
// .withResponse() returns { data: T, response: Response, request_id: string }
if (prop === 'withResponse' && typeof value === 'function') {
return function wrappedWithResponse(this: unknown): unknown {
const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target);
return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType);
};
}
return typeof value === 'function' ? value.bind(source) : value;
},
}) as Promise<R>;
}