diff --git a/packages/server-utils/src/ai/core/gen-ai-attributes.ts b/packages/server-utils/src/ai/core/gen-ai-attributes.ts new file mode 100644 index 000000000000..671dfd042165 --- /dev/null +++ b/packages/server-utils/src/ai/core/gen-ai-attributes.ts @@ -0,0 +1,51 @@ +/** + * Gen-AI telemetry attributes that are not (yet) covered by `@sentry/conventions`. + * + * Attributes with an equivalent in `@sentry/conventions/attributes` are imported from there directly + * at their call sites. The constants below either have no conventions equivalent, are Sentry-internal + * meta attributes, are span-operation values (not attribute keys), or intentionally emit a different + * key than the current conventions attribute. + * + * Based on OpenTelemetry Semantic Conventions for Generative AI + * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/ + */ + +/** + * Whether streaming was enabled for the request + */ +export const GEN_AI_REQUEST_STREAM_ATTRIBUTE = 'gen_ai.request.stream'; + +/** + * The encoding format for the model request + */ +export const GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE = 'gen_ai.request.encoding_format'; + +/** + * The dimensions for the model request + */ +export const GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE = 'gen_ai.request.dimensions'; + +/** + * The reason why the model stopped generating tokens + */ +export const GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE = 'gen_ai.response.stop_reason'; + +/** + * The span operation name for invoking an agent + */ +export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; + +/** + * The span operation for embeddings + */ +export const GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE = 'gen_ai.embeddings'; + +/** + * The span operation name for executing a tool + */ +export const GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE = 'gen_ai.execute_tool'; + +/** + * The tool call ID + */ +export const GEN_AI_TOOL_CALL_ID_ATTRIBUTE = 'gen_ai.tool.call.id'; diff --git a/packages/server-utils/src/ai/core/mediaStripping.ts b/packages/server-utils/src/ai/core/mediaStripping.ts new file mode 100644 index 000000000000..cb8e5d7b959e --- /dev/null +++ b/packages/server-utils/src/ai/core/mediaStripping.ts @@ -0,0 +1,197 @@ +/** + * Inline media content source, with a potentially very large base64 + * blob or data: uri. + */ +export type ContentMedia = Record & + ( + | { + media_type: string; + data: string; + } + | { + image_url: `data:${string}`; + } + | { + image_url: { url: `data:${string}` }; + } + | { + type: 'blob' | 'base64'; + content: string; + } + | { + b64_json: string; + } + | { + uri: `data:${string}`; + } + | { + type: 'input_audio'; + input_audio: { data: string }; + } + | { + type: 'file'; + file: { file_data?: string }; + } + ); + +/** + * Check if a content part is an OpenAI/Anthropic media source + */ +export function isContentMedia(part: unknown): part is ContentMedia { + if (!part || typeof part !== 'object') return false; + + return ( + isContentMediaSource(part) || + hasInlineData(part) || + hasImageUrl(part) || + hasInputAudio(part) || + hasFileData(part) || + hasMediaTypeData(part) || + hasVercelFileData(part) || + hasVercelImageData(part) || + hasBlobOrBase64Type(part) || + hasB64Json(part) || + hasImageGenerationResult(part) || + hasDataUri(part) + ); +} + +function hasImageUrl(part: NonNullable): boolean { + if (!('image_url' in part)) return false; + if (typeof part.image_url === 'string') return part.image_url.startsWith('data:'); + return hasNestedImageUrl(part); +} + +function hasNestedImageUrl(part: NonNullable): part is { image_url: { url: string } } { + return ( + 'image_url' in part && + !!part.image_url && + typeof part.image_url === 'object' && + 'url' in part.image_url && + typeof part.image_url.url === 'string' && + part.image_url.url.startsWith('data:') + ); +} + +function isContentMediaSource(part: NonNullable): boolean { + return 'type' in part && typeof part.type === 'string' && 'source' in part && isContentMedia(part.source); +} + +function hasInlineData(part: NonNullable): part is { inlineData: { data?: string } } { + return ( + 'inlineData' in part && + !!part.inlineData && + typeof part.inlineData === 'object' && + 'data' in part.inlineData && + typeof part.inlineData.data === 'string' + ); +} + +function hasInputAudio(part: NonNullable): part is { type: 'input_audio'; input_audio: { data: string } } { + return ( + 'type' in part && + part.type === 'input_audio' && + 'input_audio' in part && + !!part.input_audio && + typeof part.input_audio === 'object' && + 'data' in part.input_audio && + typeof part.input_audio.data === 'string' + ); +} + +function hasFileData(part: NonNullable): part is { type: 'file'; file: { file_data: string } } { + return ( + 'type' in part && + part.type === 'file' && + 'file' in part && + !!part.file && + typeof part.file === 'object' && + 'file_data' in part.file && + typeof part.file.file_data === 'string' + ); +} + +function hasMediaTypeData(part: NonNullable): part is { media_type: string; data: string } { + return 'media_type' in part && typeof part.media_type === 'string' && 'data' in part; +} + +/** + * Check for Vercel AI SDK file format: { type: "file", mediaType: "...", data: "..." } + * Only matches base64/binary data, not HTTP/HTTPS URLs (which should be preserved). + */ +function hasVercelFileData(part: NonNullable): part is { type: 'file'; mediaType: string; data: string } { + return ( + 'type' in part && + part.type === 'file' && + 'mediaType' in part && + typeof part.mediaType === 'string' && + 'data' in part && + typeof part.data === 'string' && + // Only strip base64/binary data, not HTTP/HTTPS URLs which should be preserved as references + !part.data.startsWith('http://') && + !part.data.startsWith('https://') + ); +} + +/** + * Check for Vercel AI SDK image format: { type: "image", image: "base64...", mimeType?: "..." } + * Only matches base64/data URIs, not HTTP/HTTPS URLs (which should be preserved). + * Note: mimeType is optional in Vercel AI SDK image parts. + */ +function hasVercelImageData(part: NonNullable): part is { type: 'image'; image: string; mimeType?: string } { + return ( + 'type' in part && + part.type === 'image' && + 'image' in part && + typeof part.image === 'string' && + // Only strip base64/data URIs, not HTTP/HTTPS URLs which should be preserved as references + !part.image.startsWith('http://') && + !part.image.startsWith('https://') + ); +} + +function hasBlobOrBase64Type(part: NonNullable): part is { type: 'blob' | 'base64'; content: string } { + return 'type' in part && (part.type === 'blob' || part.type === 'base64'); +} + +function hasB64Json(part: NonNullable): part is { b64_json: string } { + return 'b64_json' in part; +} + +function hasImageGenerationResult(part: NonNullable): part is { type: 'image_generation'; result: string } { + return 'type' in part && 'result' in part && part.type === 'image_generation'; +} + +function hasDataUri(part: NonNullable): part is { uri: string } { + return 'uri' in part && typeof part.uri === 'string' && part.uri.startsWith('data:'); +} + +const REMOVED_STRING = '[Blob substitute]'; + +const MEDIA_FIELDS = ['image_url', 'data', 'content', 'b64_json', 'result', 'uri', 'image'] as const; + +/** + * Replace inline binary data in a single media content part with a placeholder. + */ +export function stripInlineMediaFromSingleMessage(part: ContentMedia): ContentMedia { + const strip = { ...part }; + if (isContentMedia(strip.source)) { + strip.source = stripInlineMediaFromSingleMessage(strip.source); + } + if (hasInlineData(part)) { + strip.inlineData = { ...part.inlineData, data: REMOVED_STRING }; + } + if (hasNestedImageUrl(part)) { + strip.image_url = { ...part.image_url, url: REMOVED_STRING }; + } + if (hasInputAudio(part)) { + strip.input_audio = { ...part.input_audio, data: REMOVED_STRING }; + } + if (hasFileData(part)) { + strip.file = { ...part.file, file_data: REMOVED_STRING }; + } + for (const field of MEDIA_FIELDS) { + if (typeof strip[field] === 'string') strip[field] = REMOVED_STRING; + } + return strip; +} diff --git a/packages/server-utils/src/ai/core/messageTruncation.ts b/packages/server-utils/src/ai/core/messageTruncation.ts new file mode 100644 index 000000000000..779cf332855b --- /dev/null +++ b/packages/server-utils/src/ai/core/messageTruncation.ts @@ -0,0 +1,409 @@ +import { isContentMedia, stripInlineMediaFromSingleMessage } from './mediaStripping'; + +/** + * Default maximum size in bytes for GenAI messages. + * Messages exceeding this limit will be truncated. + */ +export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; + +/** + * Message format used by OpenAI and Anthropic APIs. + */ +type ContentMessage = { + [key: string]: unknown; + content: string; +}; + +/** + * One block inside OpenAI / Anthropic `content: [...]` arrays (text, image_url, etc.). + */ +type ContentArrayBlock = { + [key: string]: unknown; + type: string; +}; + +/** + * Message format used by OpenAI and Anthropic APIs for media. + */ +type ContentArrayMessage = { + [key: string]: unknown; + content: ContentArrayBlock[]; +}; + +/** + * Message format used by Google GenAI API. + * Parts can be strings or objects with a text property. + */ +type PartsMessage = { + [key: string]: unknown; + parts: Array; +}; + +/** + * A part in a Google GenAI message that contains text. + */ +type TextPart = string | { text: string }; + +/** + * A part in a Google GenAI that contains media. + */ +type MediaPart = { + type: string; + content: string; +}; + +/** + * One element of an array-based message: OpenAI/Anthropic `content[]` or Google `parts`. + */ +type ArrayMessageItem = TextPart | MediaPart | ContentArrayBlock; + +/** + * Calculate the UTF-8 byte length of a string. + */ +const utf8Bytes = (text: string): number => { + return new TextEncoder().encode(text).length; +}; + +/** + * Calculate the UTF-8 byte length of a value's JSON representation. + */ +const jsonBytes = (value: unknown): number => { + return utf8Bytes(JSON.stringify(value)); +}; + +/** + * Truncate a string to fit within maxBytes (inclusive) when encoded as UTF-8. + * Uses binary search for efficiency with multi-byte characters. + * + * @param text - The string to truncate + * @param maxBytes - Maximum byte length (inclusive, UTF-8 encoded) + * @returns Truncated string whose UTF-8 byte length is at most maxBytes + */ +function truncateTextByBytes(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) { + return text; + } + + let low = 0; + let high = text.length; + let bestFit = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = text.slice(0, mid); + const byteSize = utf8Bytes(candidate); + + if (byteSize <= maxBytes) { + bestFit = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return bestFit; +} + +/** + * Extract text content from a message item. + * Handles plain strings and objects with a text property. + * + * @returns The text content + */ +function getItemText(item: ArrayMessageItem): string { + if (typeof item === 'string') { + return item; + } + if ('text' in item && typeof item.text === 'string') { + return item.text; + } + return ''; +} + +/** + * Create a new item with updated text content while preserving the original structure. + * + * @param item - Original item (string or object) + * @param text - New text content + * @returns New item with updated text + */ +function withItemText(item: ArrayMessageItem, text: string): ArrayMessageItem { + if (typeof item === 'string') { + return text; + } + return { ...item, text }; +} + +/** + * Check if a message has the OpenAI/Anthropic content format. + */ +function isContentMessage(message: unknown): message is ContentMessage { + return ( + message !== null && + typeof message === 'object' && + 'content' in message && + typeof (message as ContentMessage).content === 'string' + ); +} + +/** + * Check if a message has the OpenAI/Anthropic content array format. + */ +function isContentArrayMessage(message: unknown): message is ContentArrayMessage { + return message !== null && typeof message === 'object' && 'content' in message && Array.isArray(message.content); +} + +/** + * Check if a message has the Google GenAI parts format. + */ +function isPartsMessage(message: unknown): message is PartsMessage { + return ( + message !== null && + typeof message === 'object' && + 'parts' in message && + Array.isArray((message as PartsMessage).parts) && + (message as PartsMessage).parts.length > 0 + ); +} + +/** + * Truncate a message with `content: string` format (OpenAI/Anthropic). + * + * @param message - Message with content property + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncateContentMessage(message: ContentMessage, maxBytes: number): unknown[] { + // Calculate overhead (message structure without content) + const emptyMessage = { ...message, content: '' }; + const overhead = jsonBytes(emptyMessage); + const availableForContent = maxBytes - overhead; + + if (availableForContent <= 0) { + return []; + } + + const truncatedContent = truncateTextByBytes(message.content, availableForContent); + return [{ ...message, content: truncatedContent }]; +} + +/** + * Extracts the array items and their key from an array-based message. + * Returns `null` key if neither `parts` nor `content` is a valid array. + */ +function getArrayItems(message: PartsMessage | ContentArrayMessage): { + key: 'parts' | 'content' | null; + items: ArrayMessageItem[]; +} { + if ('parts' in message && Array.isArray(message.parts)) { + return { key: 'parts', items: message.parts }; + } + if ('content' in message && Array.isArray(message.content)) { + return { key: 'content', items: message.content }; + } + return { key: null, items: [] }; +} + +/** + * Truncate a message with an array-based format. + * Handles both `parts: [...]` (Google GenAI) and `content: [...]` (OpenAI/Anthropic multimodal). + * Keeps as many complete items as possible, only truncating the first item if needed. + * + * @param message - Message with parts or content array + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncateArrayMessage(message: PartsMessage | ContentArrayMessage, maxBytes: number): unknown[] { + const { key, items } = getArrayItems(message); + + if (key === null || items.length === 0) { + return []; + } + + // Calculate overhead by creating empty text items + const emptyItems = items.map(item => withItemText(item, '')); + const overhead = jsonBytes({ ...message, [key]: emptyItems }); + let remainingBytes = maxBytes - overhead; + + if (remainingBytes <= 0) { + return []; + } + + // Include items until we run out of space + const includedItems: ArrayMessageItem[] = []; + + for (const item of items) { + const text = getItemText(item); + const textSize = utf8Bytes(text); + + if (textSize <= remainingBytes) { + // Item fits: include it as-is + includedItems.push(item); + remainingBytes -= textSize; + } else if (includedItems.length === 0) { + // First item doesn't fit: truncate it + const truncated = truncateTextByBytes(text, remainingBytes); + if (truncated) { + includedItems.push(withItemText(item, truncated)); + } + break; + } else { + // Subsequent item doesn't fit: stop here + break; + } + } + + /* c8 ignore start + * for type safety only, algorithm guarantees SOME text included */ + if (includedItems.length <= 0) { + return []; + } else { + /* c8 ignore stop */ + return [{ ...message, [key]: includedItems }]; + } +} + +/** + * Truncate a single message to fit within maxBytes. + * + * Supports three message formats: + * - OpenAI/Anthropic: `{ ..., content: string }` + * - Vercel AI/OpenAI multimodal: `{ ..., content: Array<{type, text?, ...}> }` + * - Google GenAI: `{ ..., parts: Array }` + * + * @param message - The message to truncate + * @param maxBytes - Maximum byte limit for the message + * @returns Array containing the truncated message, or empty array if truncation fails + */ +function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { + if (!message) return []; + + // Handle plain strings (e.g., embeddings input) + if (typeof message === 'string') { + const truncated = truncateTextByBytes(message, maxBytes); + return truncated ? [truncated] : []; + } + + if (typeof message !== 'object') { + return []; + } + + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } + + if (isContentArrayMessage(message) || isPartsMessage(message)) { + return truncateArrayMessage(message, maxBytes); + } + + // Unknown message format: cannot truncate safely + return []; +} + +/** + * Strip the inline media from message arrays. + * + * This returns a stripped message. We do NOT want to mutate the data in place, + * because of course we still want the actual API/client to handle the media. + */ +function stripInlineMediaFromMessages(messages: unknown[]): unknown[] { + const stripped = messages.map(message => { + let newMessage: Record | undefined = undefined; + if (!!message && typeof message === 'object') { + if (isContentArrayMessage(message)) { + newMessage = { + ...message, + content: stripInlineMediaFromMessages(message.content), + }; + } else if ('content' in message && isContentMedia(message.content)) { + newMessage = { + ...message, + content: stripInlineMediaFromSingleMessage(message.content), + }; + } + if (isPartsMessage(message)) { + newMessage = { + // might have to strip content AND parts + ...(newMessage ?? message), + parts: stripInlineMediaFromMessages(message.parts), + }; + } + if (isContentMedia(newMessage)) { + newMessage = stripInlineMediaFromSingleMessage(newMessage); + } else if (isContentMedia(message)) { + newMessage = stripInlineMediaFromSingleMessage(message); + } + } + return newMessage ?? message; + }); + return stripped; +} + +/** + * Truncate an array of messages to fit within a byte limit. + * + * Strategy: + * - Always keeps only the last (newest) message + * - Strips inline media from the message + * - Truncates the message content if it exceeds the byte limit + * + * @param messages - Array of messages to truncate + * @param maxBytes - Maximum total byte limit for the message + * @returns Array containing only the last message (possibly truncated) + * + * @example + * ```ts + * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 + * const truncated = truncateMessagesByBytes(messages, 10000); + * // Returns [msg4] (truncated if needed) + * ``` + */ +function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + // Early return for empty or invalid input + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + // The result is always a single-element array that callers wrap with + // JSON.stringify([message]), so subtract the 2-byte array wrapper ("[" and "]") + // to ensure the final serialized value stays under the limit. + const effectiveMaxBytes = maxBytes - 2; + + // Always keep only the last message + const lastMessage = messages[messages.length - 1]; + + // Strip inline media from the single message + const stripped = stripInlineMediaFromMessages([lastMessage]); + const strippedMessage = stripped[0]; + + // Check if it fits + const messageBytes = jsonBytes(strippedMessage); + if (messageBytes <= effectiveMaxBytes) { + return stripped; + } + + // Truncate the single message if needed + return truncateSingleMessage(strippedMessage, effectiveMaxBytes); +} + +/** + * Truncate GenAI messages using the default byte limit. + * + * Convenience wrapper around `truncateMessagesByBytes` with the default limit. + * + * @param messages - Array of messages to truncate + * @returns Truncated array of messages + */ +export function truncateGenAiMessages(messages: unknown[]): unknown[] { + return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} + +/** + * Truncate GenAI string input using the default byte limit. + * + * @param input - The string to truncate + * @returns Truncated string + */ +export function truncateGenAiStringInput(input: string): string { + return truncateTextByBytes(input, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts new file mode 100644 index 000000000000..6b877cc8a067 --- /dev/null +++ b/packages/server-utils/src/ai/core/utils.ts @@ -0,0 +1,326 @@ +/* eslint-disable typescript-eslint/no-deprecated */ +/** + * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) + */ +import { captureException } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { isThenable } from '@sentry/core'; +import { + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/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; + +/** + * Resolves AI recording options by falling back to the client's `dataCollection.genAI` settings. + * Precedence: explicit option > dataCollection.genAI > true (genAI data collected by default) + */ +export function resolveAIRecordingOptions(options?: T): T & Required { + const genAI = getClient()?.getDataCollectionOptions().genAI; + return { + ...options, + recordInputs: options?.recordInputs ?? genAI?.inputs ?? true, + recordOutputs: options?.recordOutputs ?? genAI?.outputs ?? true, + } as T & Required; +} + +/** + * Resolves whether truncation should be enabled. + * If the user explicitly set `enableTruncation`, that value is used. + * Otherwise, truncation is disabled because gen_ai spans are always sent through the v2 span path + * (full span streaming via `traceLifecycle: 'stream'`, or extraction into a v2 span envelope for + * static transactions). That path is not subject to the transaction payload-size limits that + * truncation works around, so the full message data can be retained. + */ +export function shouldEnableTruncation(enableTruncation: boolean | undefined): boolean { + if (enableTruncation !== undefined) { + return enableTruncation; + } + + return !getClient(); +} + +/** + * 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]: promptTokens, + }); + } + if (completionTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_OUTPUT_TOKENS]: 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]: 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 = { + [GEN_AI_RESPONSE_STREAMING]: true, + }; + + if (state.responseId) attrs[GEN_AI_RESPONSE_ID] = state.responseId; + if (state.responseModel) attrs[GEN_AI_RESPONSE_MODEL] = state.responseModel; + + if (state.promptTokens !== undefined) attrs[GEN_AI_USAGE_INPUT_TOKENS] = state.promptTokens; + if (state.completionTokens !== undefined) attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = 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] = state.totalTokens; + } else if ( + state.promptTokens !== undefined || + state.completionTokens !== undefined || + state.cacheCreationInputTokens !== undefined || + state.cacheReadInputTokens !== undefined + ) { + attrs[GEN_AI_USAGE_TOTAL_TOKENS] = + (state.promptTokens ?? 0) + + (state.completionTokens ?? 0) + + (state.cacheCreationInputTokens ?? 0) + + (state.cacheReadInputTokens ?? 0); + } + + if (state.finishReasons.length) { + attrs[GEN_AI_RESPONSE_FINISH_REASONS] = JSON.stringify(state.finishReasons); + } + if (recordOutputs && state.responseTexts.length) { + attrs[GEN_AI_RESPONSE_TEXT] = state.responseTexts.join(''); + } + if (recordOutputs && state.toolCalls.length) { + attrs[GEN_AI_RESPONSE_TOOL_CALLS] = JSON.stringify(state.toolCalls); + } + + span.setAttributes(attrs); + span.end(); +} + +/** + * Get the truncated JSON string for a string, an array of messages, or an object. + * + * @param value - The value to truncate and serialize + * @returns The truncated JSON string + */ +export function getTruncatedJsonString(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); + } + // Both truncation (media stripping recurses the value) and `JSON.stringify` can throw on + // circular refs or non-serializable values (e.g. BigInt); never let that crash instrumentation. + try { + return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value); + } catch { + return '[unserializable]'; + } +} + +/** + * 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( + originalWithResponse: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // 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( + originalPromiseLike: Promise, + instrumentedPromise: Promise, + mechanismType: string, +): Promise { + // 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; +}