diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index ec0b82cd69bc..fdca58aee958 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -12,7 +12,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { expect, it } from 'vitest'; -import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; // These tests are not exhaustive because the instrumentation is diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index a664886eafa3..21469232c101 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -53,7 +53,3 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; - -// AI instrumentation is only supported in server runtimes, so these exports are kept out of the browser entry to -// avoid shipping the AI tracing code in browser bundles. -export { getTruncatedJsonString, shouldEnableTruncation, resolveAIRecordingOptions } from './tracing/ai/utils'; diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts deleted file mode 100644 index 671dfd042165..000000000000 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 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/core/src/tracing/ai/mediaStripping.ts b/packages/core/src/tracing/ai/mediaStripping.ts deleted file mode 100644 index cb8e5d7b959e..000000000000 --- a/packages/core/src/tracing/ai/mediaStripping.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * 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/core/src/tracing/ai/messageTruncation.ts b/packages/core/src/tracing/ai/messageTruncation.ts deleted file mode 100644 index 779cf332855b..000000000000 --- a/packages/core/src/tracing/ai/messageTruncation.ts +++ /dev/null @@ -1,409 +0,0 @@ -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/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts deleted file mode 100644 index 7c4ef9a563ce..000000000000 --- a/packages/core/src/tracing/ai/utils.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* eslint-disable typescript-eslint/no-deprecated */ -/** - * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) - */ -import { captureException } from '../../exports'; -import { getClient } from '../../currentScopes'; -import type { Span } from '../../types/span'; -import { isThenable } from '../../utils/is'; -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; -} diff --git a/packages/core/test/lib/tracing/ai-message-truncation.test.ts b/packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts similarity index 99% rename from packages/core/test/lib/tracing/ai-message-truncation.test.ts rename to packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts index 951dc9be6a15..86c1933ec10e 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { truncateGenAiMessages, truncateGenAiStringInput } from '../../../src/tracing/ai/messageTruncation'; -import { getTruncatedJsonString } from '../../../src/tracing/ai/utils'; +import { truncateGenAiMessages, truncateGenAiStringInput } from '../../../../src/ai/core/messageTruncation'; +import { getTruncatedJsonString } from '../../../../src/ai/core/utils'; describe('message truncation utilities', () => { describe('truncateGenAiMessages', () => { diff --git a/packages/core/test/lib/tracing/ai/utils.test.ts b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts similarity index 98% rename from packages/core/test/lib/tracing/ai/utils.test.ts rename to packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts index c39b8673984c..d9a4fc442901 100644 --- a/packages/core/test/lib/tracing/ai/utils.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '../../../../src'; +import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core'; import { resolveAIRecordingOptions, shouldEnableTruncation, wrapPromiseWithMethods, -} from '../../../../src/tracing/ai/utils'; -import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; +} from '../../../../../src/ai/core/utils'; +import { getDefaultTestClientOptions, TestClient } from '../../../../mocks/client'; describe('resolveAIRecordingOptions', () => { beforeEach(() => {