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..21821cdc5aae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -0,0 +1,71 @@ +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..bb24b6835db2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +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..5623d3763657 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +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..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', @@ -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..64d186f927b8 --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,296 @@ +/** + * 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; +}; + +/** + * 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; +}; + +/** + * 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 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; + } + + 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 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 }]; +} + +/** + * 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 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 []; + } + + // Include parts until we run out of space + const includedParts: TextPart[] = []; + + 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; + } + } + + 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 []; + } + + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } + + if (isPartsMessage(message)) { + return truncatePartsMessage(message, maxBytes); + } + + // Unknown message format: cannot truncate safely + return []; +} + +/** + * 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; + } + + // Fast path: if all messages fit, return as-is + const totalBytes = jsonBytes(messages); + if (totalBytes <= maxBytes) { + return messages; + } + + // Precompute each message's JSON size once for efficiency + const messageSizes = messages.map(jsonBytes); + + // Find the largest suffix (newest messages) that fits within the budget + let bytesUsed = 0; + let startIndex = messages.length; // Index where the kept suffix starts + + for (let i = messages.length - 1; i >= 0; i--) { + const messageSize = messageSizes[i]; + + if (messageSize && bytesUsed + messageSize > maxBytes) { + // Adding this message would exceed the budget + break; + } + + if (messageSize) { + bytesUsed += messageSize; + } + startIndex = i; + } + + // 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); + } + + // Return the suffix that fits + return messages.slice(startIndex); +} + +/** + * 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); +} diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index ecb46d5f0d0d..00e147a16e5f 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,23 @@ 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 { + 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/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8e77dd76b34e..d81741668be9 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,7 +23,13 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -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 { @@ -33,7 +39,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { shouldInstrument } from './utils'; +import { handleResponseError, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -77,33 +83,19 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } + 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..9639b1255d29 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,7 +22,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -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'; @@ -136,17 +136,24 @@ 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[] + 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) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + const message = params.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) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + const history = params.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 4ecfad625062..bb099199772c 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 { getTruncatedJsonString } from '../ai/utils'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -191,10 +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) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.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 8f353e88d394..747a3c105449 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -6,6 +6,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -196,7 +197,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); + 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]);