Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions packages/core/src/utils/ai/messageTruncation.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind adding tests for this? You can use the files under dev-packages/node-integration-tests/suites/tracing

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);
}
21 changes: 15 additions & 6 deletions packages/core/src/utils/anthropic-ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,16 +72,24 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record<s
return attributes;
}

/**
* Add private request attributes to spans.
* This is only recorded if recordInputs is true.
*/
function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>): void {
if ('messages' in params) {
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) });
const messages = params.messages;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: JSDoc Comment Removal Ignored Reviewer's Request

JSDoc comment for addPrivateRequestAttributes function was removed despite reviewer @RulaKhaled explicitly requesting to "revert back this JSDoc comment" in the PR discussion. The original JSDoc comment should be restored.

Fix in Cursor Fix in Web

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) });
Expand Down
30 changes: 23 additions & 7 deletions packages/core/src/utils/google-genai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>): 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you revert the comment removal to help others understand the request structure? this could also be a string

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: AI Integration Functions Lack Documentation

JSDoc comments for addPrivateRequestAttributes and addRequestAttributes were removed across the Google GenAI, OpenAI, and Anthropic AI integration files. This goes against explicit reviewer feedback to restore these comments, leaving these functions undocumented.

Additional Locations (2)

Fix in Cursor Fix in Web

// 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) });
}
}
}

Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/utils/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you also revert back this JSDoc comment?

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) });
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/utils/vercel-ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Prompt Serialization Inconsistency

The gen_ai.prompt attribute is serialized inconsistently. Array prompts are JSON.stringify'd, but non-array prompts are set directly. This change from previous behavior results in inconsistent attribute types, which may impact downstream consumers.

Fix in Cursor Fix in Web

}
if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) {
span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]);
Expand Down
Loading