diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts new file mode 100644 index 000000000000..87c1a0d1204b --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,124 @@ +export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; + +/** + * Calculates the UTF-8 byte size of a string. + */ +export function getByteSize(str: string): number { + return new TextEncoder().encode(str).length; +} + +/** + * Truncates a string to fit within maxBytes using binary search. + */ +function truncateStringByBytes(str: string, maxBytes: number): string { + if (getByteSize(str) <= maxBytes) { + return str; + } + + // Binary search for the longest substring that fits + let left = 0; + let right = str.length; + let result = ''; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const candidate = str.slice(0, mid); + const candidateSize = getByteSize(candidate); + + if (candidateSize <= maxBytes) { + result = candidate; + left = mid + 1; + } else { + right = mid - 1; + } + } + + return result; +} + +/** + * Truncates messages array using binary search to find optimal starting point. + * Removes oldest messages first until the array fits within maxBytes + * It also tries to truncate the latest message's content if it's too large. + * + */ +export function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + const fullSize = getByteSize(JSON.stringify(messages)); + + if (fullSize <= maxBytes) { + return messages; + } + + // Binary search for the minimum startIndex where remaining messages fit (works for single or multiple messages) + let left = 0; + let right = messages.length - 1; + let bestStartIndex = messages.length; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const remainingMessages = messages.slice(mid); + const remainingSize = getByteSize(JSON.stringify(remainingMessages)); + + if (remainingSize <= maxBytes) { + bestStartIndex = mid; + right = mid - 1; // Try to keep more messages + } else { + // If we're down to a single message and it doesn't fit, break and handle content truncation + if (remainingMessages.length === 1) { + bestStartIndex = mid; // Use this single message + break; + } + left = mid + 1; // Need to remove more messages + } + } + + const remainingMessages = messages.slice(bestStartIndex); + + // SPECIAL CASE: Single message handling (either started with 1, or reduced to 1 after binary search) + if (remainingMessages.length === 1) { + const singleMessage = remainingMessages[0]; + const singleMessageSize = getByteSize(JSON.stringify(singleMessage)); + + // If single message fits, return it + if (singleMessageSize <= maxBytes) { + return remainingMessages; + } + + // Single message is too large, try to truncate its content + if ( + typeof singleMessage === 'object' && + singleMessage !== null && + 'content' in singleMessage && + typeof (singleMessage as { content: unknown }).content === 'string' + ) { + const originalContent = (singleMessage as { content: string }).content; + const messageWithoutContent = { ...singleMessage, content: '' }; + const otherMessagePartsSize = getByteSize(JSON.stringify(messageWithoutContent)); + const availableContentBytes = maxBytes - otherMessagePartsSize; + + if (availableContentBytes <= 0) { + return []; + } + + const truncatedContent = truncateStringByBytes(originalContent, availableContentBytes); + return [{ ...singleMessage, content: truncatedContent }]; + } else { + return []; + } + } + + // Multiple messages remain and fit within limit + return remainingMessages; +} + +/** + * Truncates gen_ai messages to fit within the default byte limit. + * This is a convenience wrapper around truncateMessagesByBytes. + */ +export function truncateGenAiMessages(messages: unknown[]): unknown[] { + return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8e77dd76b34e..4dc4df27dcaf 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,6 +23,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; @@ -71,16 +72,24 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const messages = params.messages; + if (Array.isArray(messages)) { + const truncatedMessages = truncateGenAiMessages(messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(messages) }); + } } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const input = params.input; + if (Array.isArray(input)) { + const truncatedInput = truncateGenAiMessages(input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedInput) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(input) }); + } } if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 20e6e2a53606..77466ccdeff1 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,6 +22,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; @@ -127,26 +128,41 @@ function extractRequestAttributes( return attributes; } - /** * Add private request attributes to spans. * This is only recorded if recordInputs is true. * Handles different parameter formats for different Google GenAI methods. */ function addPrivateRequestAttributes(span: Span, params: Record): void { - // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] if ('contents' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.contents) }); + const contents = params.contents; + // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] + if (Array.isArray(contents)) { + const truncatedContents = truncateGenAiMessages(contents); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedContents) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(contents) }); + } } - // For chat.sendMessage: message can be string or Part[] if ('message' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + const message = params.message; + if (Array.isArray(message)) { + const truncatedMessage = truncateGenAiMessages(message); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessage) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(message) }); + } } - // For chats.create: history contains the conversation history if ('history' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + const history = params.history; + if (Array.isArray(history)) { + const truncatedHistory = truncateGenAiMessages(history); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedHistory) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(history) }); + } } } diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 4ecfad625062..7613dde5038b 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -19,6 +19,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -188,13 +189,24 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool } } -// Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const messages = params.messages; + if (Array.isArray(messages)) { + const truncatedMessages = truncateGenAiMessages(messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(messages) }); + } } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const input = params.input; + if (Array.isArray(input)) { + const truncatedInput = truncateGenAiMessages(input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedInput) }); + } else { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(input) }); + } } } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 9b1cc2bc8aae..238ba845f918 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -3,6 +3,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; import { spanToJSON } from '../spanUtils'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; import { accumulateTokensForParent, applyAccumulatedTokens } from './utils'; @@ -190,7 +191,13 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + const prompt = attributes[AI_PROMPT_ATTRIBUTE]; + if (Array.isArray(prompt)) { + const truncatedPrompt = truncateGenAiMessages(prompt); + span.setAttribute('gen_ai.prompt', JSON.stringify(truncatedPrompt)); + } else { + span.setAttribute('gen_ai.prompt', prompt); + } } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]);