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
38 changes: 34 additions & 4 deletions sentry_sdk/ai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@ def get_start_span_function():
return sentry_sdk.start_span if transaction_exists else sentry_sdk.start_transaction


def _truncate_single_message(message, max_bytes):
# type: (Dict[str, Any], int) -> Dict[str, Any]
"""
Truncate a single message to fit within max_bytes.
If the message is too large, truncate the content field.
"""
if not isinstance(message, dict) or "content" not in message:
return message
content = message.get("content", "")

if not isinstance(content, str) or len(content) <= max_bytes:
return message

overhead_message = message.copy()
overhead_message["content"] = ""
overhead_size = len(
json.dumps(overhead_message, separators=(",", ":")).encode("utf-8")
)

available_content_bytes = max_bytes - overhead_size - 20
message["content"] = content[:available_content_bytes] + "..."
Copy link

Choose a reason for hiding this comment

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

Bug: In-place Mutation of Message Content Causes Side Effects

The function mutates the original message dict instead of creating a copy before modification. This causes unintended side effects where the caller's input data is modified. Similar to normalize_message_roles, this function should create a copy of the message before modifying its content field.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

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

Bug: UTF-8 Truncation Mismatch Causes Encoding Errors

The truncation uses content[:available_content_bytes] which slices by character index rather than byte length. For multi-byte UTF-8 characters like emoji or non-ASCII text, this produces incorrect results since available_content_bytes is a byte count but string slicing operates on character positions. This can truncate too many or too few bytes, and may slice mid-character causing encoding errors.

Fix in Cursor Fix in Web

return message


def _find_truncation_index(messages, max_bytes):
# type: (List[Dict[str, Any]], int) -> int
"""
Expand All @@ -120,14 +144,20 @@ def _find_truncation_index(messages, max_bytes):

def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we instead check if the returned array has one element, and if so, truncate the single message?

Let me know if I am missing some edge case that wouldn't be covered that way.

# type: (List[Dict[str, Any]], int) -> Tuple[List[Dict[str, Any]], int]
serialized_json = json.dumps(messages, separators=(",", ":"))
messages_with_truncated_content = [
_truncate_single_message(msg, max_bytes) for msg in messages
]

serialized_json = json.dumps(messages_with_truncated_content, separators=(",", ":"))
current_size = len(serialized_json.encode("utf-8"))

if current_size <= max_bytes:
return messages, 0
return messages_with_truncated_content, 0

truncation_index = _find_truncation_index(messages, max_bytes)
return messages[truncation_index:], truncation_index
truncation_index = _find_truncation_index(
messages_with_truncated_content, max_bytes
)
return messages_with_truncated_content[truncation_index:], truncation_index


def truncate_and_annotate_messages(
Expand Down
78 changes: 78 additions & 0 deletions tests/test_ai_monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,84 @@ def test_progressive_truncation(self, large_messages):
assert current_count >= 1
prev_count = current_count

def test_individual_message_truncation(self):
large_content = "This is a very long message. " * 1000

messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": large_content},
]

result, truncation_index = truncate_messages_by_size(
messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES
)

assert len(result) > 0

total_size = len(json.dumps(result, separators=(",", ":")).encode("utf-8"))
assert total_size <= MAX_GEN_AI_MESSAGE_BYTES

for msg in result:
msg_size = len(json.dumps(msg, separators=(",", ":")).encode("utf-8"))
assert msg_size <= MAX_GEN_AI_MESSAGE_BYTES

# If the last message is too large, the system message is not present
system_msgs = [m for m in result if m.get("role") == "system"]
assert len(system_msgs) == 0

# Confirm the user message is truncated with '...'
user_msgs = [m for m in result if m.get("role") == "user"]
assert len(user_msgs) == 1
assert user_msgs[0]["content"].endswith("...")
assert len(user_msgs[0]["content"]) < len(large_content)

def test_combined_individual_and_array_truncation(self):
huge_content = "X" * 25000
medium_content = "Y" * 5000

messages = [
{"role": "system", "content": medium_content},
{"role": "user", "content": huge_content},
{"role": "assistant", "content": medium_content},
{"role": "user", "content": "small"},
]

result, truncation_index = truncate_messages_by_size(
messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES
)

assert len(result) > 0

total_size = len(json.dumps(result, separators=(",", ":")).encode("utf-8"))
assert total_size <= MAX_GEN_AI_MESSAGE_BYTES

for msg in result:
msg_size = len(json.dumps(msg, separators=(",", ":")).encode("utf-8"))
assert msg_size <= MAX_GEN_AI_MESSAGE_BYTES

# The last user "small" message should always be present and untruncated
last_user_msgs = [
m for m in result if m.get("role") == "user" and m["content"] == "small"
]
assert len(last_user_msgs) == 1

# If the huge message is present, it must be truncated
for user_msg in [
m for m in result if m.get("role") == "user" and "X" in m["content"]
]:
assert user_msg["content"].endswith("...")
assert len(user_msg["content"]) < len(huge_content)

# The medium messages, if present, should not be truncated
for expected_role in ["system", "assistant"]:
role_msgs = [m for m in result if m.get("role") == expected_role]
if role_msgs:
assert role_msgs[0]["content"].startswith("Y")
assert len(role_msgs[0]["content"]) <= len(medium_content)
assert not role_msgs[0]["content"].endswith("...") or len(
role_msgs[0]["content"]
) == len(medium_content)


class TestTruncateAndAnnotateMessages:
def test_no_truncation_returns_list(self, sample_messages):
Expand Down