From 0334ebf692353480d3f2777285f79be021a9a441 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 13 Oct 2025 16:47:18 +0200 Subject: [PATCH 1/7] feat(core): Truncate request messages in AI integrations --- .../anthropic/scenario-message-truncation.mjs | 72 ++++++++ .../suites/tracing/anthropic/test.ts | 36 ++++ .../scenario-message-truncation.mjs | 70 ++++++++ .../suites/tracing/google-genai/test.ts | 38 +++++ .../openai/scenario-message-truncation.mjs | 70 ++++++++ .../suites/tracing/openai/test.ts | 36 ++++ .../core/src/utils/ai/messageTruncation.ts | 154 ++++++++++++++++++ packages/core/src/utils/anthropic-ai/index.ts | 38 ++--- packages/core/src/utils/anthropic-ai/utils.ts | 22 ++- packages/core/src/utils/google-genai/index.ts | 26 ++- packages/core/src/utils/openai/index.ts | 17 +- packages/core/src/utils/vercel-ai/index.ts | 9 +- 12 files changed, 561 insertions(+), 27 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs create mode 100644 packages/core/src/utils/ai/messageTruncation.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs new file mode 100644 index 000000000000..06e32288acb2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -0,0 +1,72 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.baseURL = config.baseURL; + + // Create messages object with create method + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'msg-truncation-test', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response to truncated messages', + }, + ], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: largeContent1 }, + { role: 'assistant', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); + diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index c05db16fc251..57e788b721d1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -497,4 +497,40 @@ describe('Anthropic integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_ERROR_SPANS }).start().completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + // Messages should be present (truncation happened) and should be a JSON array + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..52507c69ba72 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -0,0 +1,70 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + response: { + text: () => 'Response to truncated messages', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 15, + totalTokenCount: 25, + }, + candidates: [ + { + content: { + parts: [{ text: 'Response to truncated messages' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentGoogleGenAIClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { role: 'user', parts: [{ text: largeContent1 }] }, + { role: 'model', parts: [{ text: largeContent2 }] }, + { role: 'user', parts: [{ text: largeContent3 }] }, + ], + }); + }); +} + +run(); + diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 92d669c7e10f..921f94e78765 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -486,4 +486,42 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + // Messages should be present (truncation happened) and should be a JSON array with parts + 'gen_ai.request.messages': expect.stringMatching( + /^\[\{"role":"user","parts":\[\{"text":"C+"\}\]\}\]$/, + ), + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..2a3ea3091bf8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -0,0 +1,70 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'chatcmpl-truncation-test', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Response to truncated messages', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + }, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: largeContent1 }, + { role: 'user', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); + diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index c0c0b79e95f7..8f521638443b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -397,4 +397,40 @@ describe('OpenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + // Messages should be present (truncation happened) and should be a JSON array of a single index + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts new file mode 100644 index 000000000000..31100ab0356b --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,154 @@ +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; +} + +/** + * Attempts to truncate a single message's content to fit within maxBytes. + * Supports both OpenAI/Anthropic format and Google GenAI format. + * Returns the truncated message or an empty array if truncation is not possible. + */ +function truncateSingleMessageContent(message: unknown, maxBytes: number): unknown[] { + if (typeof message !== 'object' || message === null) { + return []; + } + + // Handle OpenAI/Anthropic format: { role: 'user', content: 'text' } + if ('content' in message && typeof (message as { content: unknown }).content === 'string') { + const originalContent = (message as { content: string }).content; + const messageWithoutContent = { ...message, content: '' }; + const overhead = getByteSize(JSON.stringify(messageWithoutContent)); + const availableBytes = maxBytes - overhead; + + if (availableBytes <= 0) { + return []; + } + + const truncatedContent = truncateStringByBytes(originalContent, availableBytes); + return [{ ...message, content: truncatedContent }]; + } + + // Handle Google GenAI format: { role: 'user', parts: [{ text: 'text' }] } + if ( + 'parts' in message && + Array.isArray((message as { parts: unknown }).parts) && + (message as { parts: unknown[] }).parts.length > 0 + ) { + const parts = (message as { parts: { text?: unknown }[] }).parts; + const firstPart = parts[0]; + + if (firstPart && typeof firstPart === 'object' && 'text' in firstPart && typeof firstPart.text === 'string') { + const originalText = firstPart.text; + const messageWithEmptyText = { ...message, parts: [{ ...firstPart, text: '' }] }; + const overhead = getByteSize(JSON.stringify(messageWithEmptyText)); + const availableBytes = maxBytes - overhead; + + if (availableBytes <= 0) { + return []; + } + + const truncatedText = truncateStringByBytes(originalText, availableBytes); + return [{ ...message, parts: [{ ...firstPart, text: truncatedText }] }]; + } + } + + // Unknown format - cannot truncate + return []; +} + +/** + * Truncates messages array using binary search to find optimal starting point. + * Removes oldest messages first until the array fits within maxBytes. + * If only one message remains and it's too large, truncates its content. + */ +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 + 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; + break; + } + left = mid + 1; // Need to remove more messages + } + } + + const remainingMessages = messages.slice(bestStartIndex); + + // If only one message remains, check if it fits or needs content truncation + if (remainingMessages.length === 1) { + const singleMessageSize = getByteSize(JSON.stringify(remainingMessages[0])); + + if (singleMessageSize <= maxBytes) { + return remainingMessages; + } + + // Single message is too large, try to truncate its content + return truncateSingleMessageContent(remainingMessages[0], maxBytes); + } + + // 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..fc62de946f26 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'; @@ -33,7 +34,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { shouldInstrument } from './utils'; +import { handleResponseError, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -77,33 +78,30 @@ 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) }); } } -/** - * Capture error information from the response - * @see https://docs.anthropic.com/en/api/errors#error-shapes - */ -function handleResponseError(span: Span, response: AnthropicAiResponse): void { - if (response.error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); - - captureException(response.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); - } -} - /** * Add content attributes when recordOutputs is enabled */ diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts index 299d20170d6c..bce96aa68bcc 100644 --- a/packages/core/src/utils/anthropic-ai/utils.ts +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -1,5 +1,8 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod } from './types'; +import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; /** * Check if a method path should be instrumented @@ -7,3 +10,20 @@ import type { AnthropicAiInstrumentedMethod } from './types'; export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } + +/** + * Capture error information from the response + * @see https://docs.anthropic.com/en/api/errors#error-shapes + */ +export function handleResponseError(span: Span, response: AnthropicAiResponse): void { + if (response.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); + + captureException(response.error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.anthropic_error', + }, + }); + } +} diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 20e6e2a53606..17da0ef79016 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'; @@ -136,17 +137,36 @@ function extractRequestAttributes( 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..cb66bdecd29d 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 { @@ -191,10 +192,22 @@ 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..1f33ee9efe6f 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -2,6 +2,7 @@ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; +import { truncateGenAiMessages } from '../ai/messageTruncation'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -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]); From bf8e8124648fdc9b27c6b03007fa2c4d59d8fa22 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 13 Oct 2025 17:46:14 +0200 Subject: [PATCH 2/7] update truncation --- .../core/src/utils/ai/messageTruncation.ts | 328 +++++++++++++----- 1 file changed, 232 insertions(+), 96 deletions(-) diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts index 31100ab0356b..aefd3aecd7db 100644 --- a/packages/core/src/utils/ai/messageTruncation.ts +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -1,153 +1,289 @@ +/** + * Default maximum size in bytes for GenAI messages. + * Messages exceeding this limit will be truncated. + */ export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; /** - * Calculates the UTF-8 byte size of a string. + * Message format used by OpenAI and Anthropic APIs. */ -export function getByteSize(str: string): number { - return new TextEncoder().encode(str).length; -} +type ContentMessage = { [key: string]: unknown; content: string }; + +/** + * 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 }; + +/** + * Calculate the UTF-8 byte length of a string. + */ +const utf8Bytes = (text: string): number => { + return new TextEncoder().encode(text).length; +}; /** - * Truncates a string to fit within maxBytes using binary search. + * Calculate the UTF-8 byte length of a value's JSON representation. */ -function truncateStringByBytes(str: string, maxBytes: number): string { - if (getByteSize(str) <= maxBytes) { - return str; +const jsonBytes = (value: unknown): number => { + return utf8Bytes(JSON.stringify(value)); +}; + +/** + * Truncate a string to fit within maxBytes 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 (UTF-8 encoded) + * @returns Truncated string that fits within maxBytes + */ +function truncateTextByBytes(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) { + return text; } - // Binary search for the longest substring that fits - let left = 0; - let right = str.length; - let result = ''; + let low = 0; + let high = text.length; + let bestFit = ''; - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const candidate = str.slice(0, mid); - const candidateSize = getByteSize(candidate); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = text.slice(0, mid); + const byteSize = utf8Bytes(candidate); - if (candidateSize <= maxBytes) { - result = candidate; - left = mid + 1; + if (byteSize <= maxBytes) { + bestFit = candidate; + low = mid + 1; } else { - right = mid - 1; + high = mid - 1; } } - return result; + return bestFit; +} + +/** + * Extract text content from a Google GenAI message part. + * Parts are either plain strings or objects with a text property. + * + * @returns The text content + */ +function getPartText(part: TextPart): string { + if (typeof part === 'string') { + return part; + } + return part.text; +} + +/** + * Create a new part with updated text content while preserving the original structure. + * + * @param part - Original part (string or object) + * @param text - New text content + * @returns New part with updated text + */ +function withPartText(part: TextPart, text: string): TextPart { + if (typeof part === 'string') { + return text; + } + return { ...part, 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 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 }]; } /** - * Attempts to truncate a single message's content to fit within maxBytes. - * Supports both OpenAI/Anthropic format and Google GenAI format. - * Returns the truncated message or an empty array if truncation is not possible. + * Truncate a message with `parts: [...]` format (Google GenAI). + * Keeps as many complete parts as possible, only truncating the first part if needed. + * + * @param message - Message with parts array + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit */ -function truncateSingleMessageContent(message: unknown, maxBytes: number): unknown[] { - if (typeof message !== 'object' || message === null) { +function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { + const { parts } = message; + + // Calculate overhead by creating empty text parts + const emptyParts = parts.map(part => withPartText(part, '')); + const overhead = jsonBytes({ ...message, parts: emptyParts }); + let remainingBytes = maxBytes - overhead; + + if (remainingBytes <= 0) { return []; } - // Handle OpenAI/Anthropic format: { role: 'user', content: 'text' } - if ('content' in message && typeof (message as { content: unknown }).content === 'string') { - const originalContent = (message as { content: string }).content; - const messageWithoutContent = { ...message, content: '' }; - const overhead = getByteSize(JSON.stringify(messageWithoutContent)); - const availableBytes = maxBytes - overhead; + // Include parts until we run out of space + const includedParts: TextPart[] = []; - if (availableBytes <= 0) { - return []; + for (const part of parts) { + const text = getPartText(part); + const textSize = utf8Bytes(text); + + if (textSize <= remainingBytes) { + // Part fits: include it as-is + includedParts.push(part); + remainingBytes -= textSize; + } else if (includedParts.length === 0) { + // First part doesn't fit: truncate it + const truncated = truncateTextByBytes(text, remainingBytes); + if (truncated) { + includedParts.push(withPartText(part, truncated)); + } + break; + } else { + // Subsequent part doesn't fit: stop here + break; } + } - const truncatedContent = truncateStringByBytes(originalContent, availableBytes); - return [{ ...message, content: truncatedContent }]; + return includedParts.length > 0 ? [{ ...message, parts: includedParts }] : []; +} + +/** + * Truncate a single message to fit within maxBytes. + * + * Supports two message formats: + * - OpenAI/Anthropic: `{ ..., content: string }` + * - 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 || typeof message !== 'object') { + return []; } - // Handle Google GenAI format: { role: 'user', parts: [{ text: 'text' }] } - if ( - 'parts' in message && - Array.isArray((message as { parts: unknown }).parts) && - (message as { parts: unknown[] }).parts.length > 0 - ) { - const parts = (message as { parts: { text?: unknown }[] }).parts; - const firstPart = parts[0]; - - if (firstPart && typeof firstPart === 'object' && 'text' in firstPart && typeof firstPart.text === 'string') { - const originalText = firstPart.text; - const messageWithEmptyText = { ...message, parts: [{ ...firstPart, text: '' }] }; - const overhead = getByteSize(JSON.stringify(messageWithEmptyText)); - const availableBytes = maxBytes - overhead; - - if (availableBytes <= 0) { - return []; - } + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } - const truncatedText = truncateStringByBytes(originalText, availableBytes); - return [{ ...message, parts: [{ ...firstPart, text: truncatedText }] }]; - } + if (isPartsMessage(message)) { + return truncatePartsMessage(message, maxBytes); } - // Unknown format - cannot truncate + // Unknown message format: cannot truncate safely return []; } /** - * Truncates messages array using binary search to find optimal starting point. - * Removes oldest messages first until the array fits within maxBytes. - * If only one message remains and it's too large, truncates its content. + * Truncate an array of messages to fit within a byte limit. + * + * Strategy: + * - Keeps the newest messages (from the end of the array) + * - Uses O(n) algorithm: precompute sizes once, then find largest suffix under budget + * - If no complete messages fit, attempts to truncate the newest single message + * + * @param messages - Array of messages to truncate + * @param maxBytes - Maximum total byte limit for all messages + * @returns Truncated array of messages + * + * @example + * ```ts + * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 + * const truncated = truncateMessagesByBytes(messages, 10000); + * // Returns [msg3, msg4] if they fit, or [msg4] if only it fits, etc. + * ``` */ export function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + // Early return for empty or invalid input if (!Array.isArray(messages) || messages.length === 0) { return messages; } - const fullSize = getByteSize(JSON.stringify(messages)); - if (fullSize <= maxBytes) { + // Fast path: if all messages fit, return as-is + const totalBytes = jsonBytes(messages); + if (totalBytes <= maxBytes) { return messages; } - // Binary search for the minimum startIndex where remaining messages fit - 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)); + // Precompute each message's JSON size once for efficiency + const messageSizes = messages.map(jsonBytes); - 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; - break; - } - left = mid + 1; // Need to remove more messages - } - } + // Find the largest suffix (newest messages) that fits within the budget + let bytesUsed = 0; + let startIndex = messages.length; // Index where the kept suffix starts - const remainingMessages = messages.slice(bestStartIndex); + for (let i = messages.length - 1; i >= 0; i--) { + const messageSize = messageSizes[i]; - // If only one message remains, check if it fits or needs content truncation - if (remainingMessages.length === 1) { - const singleMessageSize = getByteSize(JSON.stringify(remainingMessages[0])); + if (messageSize && bytesUsed + messageSize > maxBytes) { + // Adding this message would exceed the budget + break; + } - if (singleMessageSize <= maxBytes) { - return remainingMessages; + if (messageSize) { + bytesUsed += messageSize; } + startIndex = i; + } - // Single message is too large, try to truncate its content - return truncateSingleMessageContent(remainingMessages[0], maxBytes); + // If no complete messages fit, try truncating just the newest message + if (startIndex === messages.length) { + const newestMessage = messages[messages.length - 1]; + return truncateSingleMessage(newestMessage, maxBytes); } - // Multiple messages remain and fit within limit - return remainingMessages; + // Return the suffix that fits + return messages.slice(startIndex); } /** - * Truncates gen_ai messages to fit within the default byte limit. - * This is a convenience wrapper around truncateMessagesByBytes. + * 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); From a1bd5360ede9dbec5a08c8b77706b73a8cffd113 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 14 Oct 2025 11:17:43 +0200 Subject: [PATCH 3/7] lint yelling: --- .../suites/tracing/anthropic/scenario-message-truncation.mjs | 1 - .../suites/tracing/google-genai/scenario-message-truncation.mjs | 1 - .../suites/tracing/openai/scenario-message-truncation.mjs | 1 - 3 files changed, 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs index 06e32288acb2..21821cdc5aae 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -69,4 +69,3 @@ async function run() { } run(); - diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs index 52507c69ba72..bb24b6835db2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -67,4 +67,3 @@ async function run() { } run(); - diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs index 2a3ea3091bf8..5623d3763657 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -67,4 +67,3 @@ async function run() { } run(); - From 94a72d5af4f5648efa81fa6271a62a162a8e191d Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 20 Oct 2025 10:03:05 +0200 Subject: [PATCH 4/7] update with nicer format --- packages/core/src/utils/ai/messageTruncation.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts index aefd3aecd7db..64d186f927b8 100644 --- a/packages/core/src/utils/ai/messageTruncation.ts +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -7,13 +7,19 @@ export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; /** * Message format used by OpenAI and Anthropic APIs. */ -type ContentMessage = { [key: string]: unknown; content: string }; +type ContentMessage = { + [key: string]: unknown; + content: string; +}; /** * Message format used by Google GenAI API. * Parts can be strings or objects with a text property. */ -type PartsMessage = { [key: string]: unknown; parts: Array }; +type PartsMessage = { + [key: string]: unknown; + parts: Array; +}; /** * A part in a Google GenAI message that contains text. From 8faecc8510a58ede90efbbf9227924ec014d5ffb Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 21 Oct 2025 16:45:17 +0200 Subject: [PATCH 5/7] refactor duplicate code --- packages/core/src/utils/ai/utils.ts | 11 ++++++++ packages/core/src/utils/anthropic-ai/index.ts | 28 ++++++++----------- packages/core/src/utils/google-genai/index.ts | 27 +++++------------- packages/core/src/utils/openai/index.ts | 20 ++++--------- packages/core/src/utils/vercel-ai/index.ts | 11 ++------ 5 files changed, 37 insertions(+), 60 deletions(-) diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index ecb46d5f0d0d..2bc45968bf09 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -7,6 +7,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from './gen-ai-attributes'; +import { truncateGenAiMessages } from './messageTruncation'; /** * Maps AI method paths to Sentry operation name */ @@ -84,3 +85,13 @@ export function setTokenUsageAttributes( }); } } + +/** + * 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(value: T | T[]): string { + return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value); +} diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index fc62de946f26..d81741668be9 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,8 +23,13 @@ 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 { + buildMethodPath, + getFinalOperationName, + getSpanOperation, + getTruncatedJsonString, + setTokenUsageAttributes, +} from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -78,23 +83,12 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - 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) }); - } + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - 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) }); - } + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } if ('prompt' in params) { diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 17da0ef79016..9639b1255d29 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,8 +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 { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -139,34 +138,22 @@ function addPrivateRequestAttributes(span: Span, params: Record if ('contents' in params) { 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) }); - } + const truncatedContents = getTruncatedJsonString(contents); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedContents }); } // For chat.sendMessage: message can be string or Part[] if ('message' in params) { 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) }); - } + const truncatedMessage = getTruncatedJsonString(message); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessage }); } // For chats.create: history contains the conversation history if ('history' in params) { 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) }); - } + const truncatedHistory = getTruncatedJsonString(history); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedHistory }); } } diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index cb66bdecd29d..bb099199772c 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -19,7 +19,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { truncateGenAiMessages } from '../ai/messageTruncation'; +import { getTruncatedJsonString } from '../ai/utils'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -192,22 +192,12 @@ 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) { - 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) }); - } + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - 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) }); - } + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 3f7b81991c30..72d52deca203 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -6,7 +6,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { truncateGenAiMessages } from '../ai/messageTruncation'; +import { getTruncatedJsonString } from '../ai/utils'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -197,13 +197,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (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); - } + const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); + span.setAttribute('gen_ai.prompt', JSON.stringify(truncatedPrompt)); } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); From aa64b94e746a1ce7664e49fa8056de6090a5c581 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 21 Oct 2025 17:33:00 +0200 Subject: [PATCH 6/7] handle stringified objects too --- packages/core/src/utils/ai/utils.ts | 12 +++++++++++- packages/core/src/utils/vercel-ai/index.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index 2bc45968bf09..00e147a16e5f 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -93,5 +93,15 @@ export function setTokenUsageAttributes( * @returns The truncated JSON string */ export function getTruncatedJsonString(value: T | T[]): string { - return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value); + if (typeof value === 'string') { + // Some values are already JSON strings, so we don't need to duplicate the JSON parsing + return 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); } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 72d52deca203..747a3c105449 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -198,7 +198,7 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute if (attributes[AI_PROMPT_ATTRIBUTE]) { const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); - span.setAttribute('gen_ai.prompt', JSON.stringify(truncatedPrompt)); + span.setAttribute('gen_ai.prompt', truncatedPrompt); } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); From eda8ec00ec7c64b493d5033731cacf7e699be443 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Tue, 21 Oct 2025 17:54:50 +0200 Subject: [PATCH 7/7] fix test --- .../node-integration-tests/suites/tracing/openai/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 8f521638443b..8c788834f126 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -187,7 +187,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', - 'gen_ai.request.messages': '"Translate this to French: Hello"', + 'gen_ai.request.messages': 'Translate this to French: Hello', 'gen_ai.response.text': 'Response to: Translate this to French: Hello', 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -261,7 +261,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, - 'gen_ai.request.messages': '"Test streaming responses API"', + 'gen_ai.request.messages': 'Test streaming responses API', 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', 'gen_ai.response.finish_reasons': '["in_progress","completed"]', 'gen_ai.response.id': 'resp_stream_456',