From fc3397846f0d58932ff05a04a62015dd46375c19 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 7 Nov 2025 09:38:46 +0100 Subject: [PATCH 1/7] feat(ai): add single message truncation --- sentry_sdk/ai/utils.py | 38 ++++++++++++++++-- tests/test_ai_monitoring.py | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 06c9a23604..9d585d5375 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -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] + "..." + return message + + def _find_truncation_index(messages, max_bytes): # type: (List[Dict[str, Any]], int) -> int """ @@ -120,14 +144,20 @@ def _find_truncation_index(messages, max_bytes): def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES): # 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( diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py index 5ff136f810..16982a2a17 100644 --- a/tests/test_ai_monitoring.py +++ b/tests/test_ai_monitoring.py @@ -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): From 6830d12e7216874d8bc23066ff3c58c80bc8665f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 10:26:21 +0100 Subject: [PATCH 2/7] Truncate single messages based on characters --- sentry_sdk/ai/utils.py | 57 +++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 9d585d5375..9568fd4564 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -1,4 +1,5 @@ import json +from copy import deepcopy from collections import deque from typing import TYPE_CHECKING from sys import getsizeof @@ -12,6 +13,8 @@ from sentry_sdk.utils import logger MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB +# Maximum characters when only a single message is left after bytes truncation +MAX_SINGLE_MESSAGE_CONTENT_CHARS = 10_000 class GEN_AI_ALLOWED_MESSAGE_ROLES: @@ -101,27 +104,20 @@ 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): +def _truncate_single_message_content_if_present(message, max_chars): # type: (Dict[str, Any], int) -> Dict[str, Any] """ - Truncate a single message to fit within max_bytes. + Truncate a single message to fit within max_chars. 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", "") + content = message["content"] - if not isinstance(content, str) or len(content) <= max_bytes: + if not isinstance(content, str) or len(content) <= max_chars: 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] + "..." + message["content"] = content[:max_chars] + "..." return message @@ -142,22 +138,37 @@ def _find_truncation_index(messages, max_bytes): return 0 -def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES): - # type: (List[Dict[str, Any]], int) -> Tuple[List[Dict[str, Any]], int] - messages_with_truncated_content = [ - _truncate_single_message(msg, max_bytes) for msg in messages - ] - - serialized_json = json.dumps(messages_with_truncated_content, separators=(",", ":")) +def truncate_messages_by_size( + messages, + max_bytes=MAX_GEN_AI_MESSAGE_BYTES, + max_single_message_chars=MAX_SINGLE_MESSAGE_CONTENT_CHARS, +): + # type: (List[Dict[str, Any]], int, int) -> Tuple[List[Dict[str, Any]], int] + """ + Returns a truncated messages array, consisting of + - the last message, with the messages's content truncated to `max_single_message_chars` characters, + if the last message's size exceeds `max_bytes`; otherwise, + - the maximum number of messages, starting from the end of the `messages` array, whose total + serialized size does not exceed `max_bytes` bytes. + """ + serialized_json = json.dumps(messages, separators=(",", ":")) current_size = len(serialized_json.encode("utf-8")) if current_size <= max_bytes: - return messages_with_truncated_content, 0 + return messages, 0 - truncation_index = _find_truncation_index( - messages_with_truncated_content, max_bytes + truncation_index = _find_truncation_index(messages, max_bytes) + truncated_messages = ( + messages[truncation_index:] + if truncation_index < len(messages) + else messages[-1:] ) - return messages_with_truncated_content[truncation_index:], truncation_index + if len(truncated_messages) == 1: + truncated_messages[0] = _truncate_single_message_content_if_present( + deepcopy(truncated_messages[0]), max_chars=max_single_message_chars + ) + + return truncated_messages, truncation_index def truncate_and_annotate_messages( From 205cb6711766d3123faf4b91f632ad92eda5c12e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 10:55:22 +0100 Subject: [PATCH 3/7] update tests --- tests/test_ai_monitoring.py | 70 +++++-------------------------------- 1 file changed, 9 insertions(+), 61 deletions(-) diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py index 16982a2a17..2856877192 100644 --- a/tests/test_ai_monitoring.py +++ b/tests/test_ai_monitoring.py @@ -8,6 +8,7 @@ from sentry_sdk.ai.monitoring import ai_track from sentry_sdk.ai.utils import ( MAX_GEN_AI_MESSAGE_BYTES, + MAX_SINGLE_MESSAGE_CONTENT_CHARS, set_data_normalized, truncate_and_annotate_messages, truncate_messages_by_size, @@ -177,7 +178,6 @@ async def async_tool(**kwargs): @pytest.fixture def sample_messages(): - """Sample messages similar to what gen_ai integrations would use""" return [ {"role": "system", "content": "You are a helpful assistant."}, { @@ -226,8 +226,7 @@ def test_truncation_removes_oldest_first(self, large_messages): ) assert len(result) < len(large_messages) - if result: - assert result[-1] == large_messages[-1] + assert result[-1] == large_messages[-1] assert truncation_index == len(large_messages) - len(result) def test_empty_messages_list(self): @@ -278,8 +277,8 @@ 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 + def test_single_message_truncation(self): + large_content = "This is a very long message. " * 10_000 messages = [ {"role": "system", "content": "You are a helpful assistant."}, @@ -287,17 +286,13 @@ def test_individual_message_truncation(self): ] result, truncation_index = truncate_messages_by_size( - messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES + messages, max_single_message_chars=MAX_SINGLE_MESSAGE_CONTENT_CHARS ) - 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 + assert len(result) == 1 + assert ( + len(result[0]["content"].rstrip("...")) <= MAX_SINGLE_MESSAGE_CONTENT_CHARS + ) # 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"] @@ -309,53 +304,6 @@ def test_individual_message_truncation(self): 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): From 99218badac3dfa8083bc7e935fbc10dffc3657e9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 10:56:54 +0100 Subject: [PATCH 4/7] . --- sentry_sdk/ai/utils.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 9568fd4564..83cbb2db59 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -107,8 +107,8 @@ def get_start_span_function(): def _truncate_single_message_content_if_present(message, max_chars): # type: (Dict[str, Any], int) -> Dict[str, Any] """ - Truncate a single message to fit within max_chars. - If the message is too large, truncate the content field. + Truncate a message's content to at most `max_chars` characters and append an + ellipsis if truncation occurs. """ if not isinstance(message, dict) or "content" not in message: return message @@ -146,10 +146,13 @@ def truncate_messages_by_size( # type: (List[Dict[str, Any]], int, int) -> Tuple[List[Dict[str, Any]], int] """ Returns a truncated messages array, consisting of - - the last message, with the messages's content truncated to `max_single_message_chars` characters, + - the last message, with its content truncated to `max_single_message_chars` characters, if the last message's size exceeds `max_bytes`; otherwise, - the maximum number of messages, starting from the end of the `messages` array, whose total serialized size does not exceed `max_bytes` bytes. + + In the single message case, the serialized message size may exceed `max_bytes`, because + truncation is based only on character count in that case. """ serialized_json = json.dumps(messages, separators=(",", ":")) current_size = len(serialized_json.encode("utf-8")) @@ -158,11 +161,12 @@ def truncate_messages_by_size( return messages, 0 truncation_index = _find_truncation_index(messages, max_bytes) - truncated_messages = ( - messages[truncation_index:] - if truncation_index < len(messages) - else messages[-1:] - ) + if truncation_index < len(messages): + truncated_messages = messages[truncation_index:] + else: + truncation_index = len(messages) - 1 + truncated_messages = messages[-1:] + if len(truncated_messages) == 1: truncated_messages[0] = _truncate_single_message_content_if_present( deepcopy(truncated_messages[0]), max_chars=max_single_message_chars From a4a3fef672061bf13d4c8b0014a7cb6211e58944 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 11:01:48 +0100 Subject: [PATCH 5/7] . --- tests/test_ai_monitoring.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py index 2856877192..8d3d4ba204 100644 --- a/tests/test_ai_monitoring.py +++ b/tests/test_ai_monitoring.py @@ -178,6 +178,7 @@ async def async_tool(**kwargs): @pytest.fixture def sample_messages(): + """Sample messages similar to what gen_ai integrations would use""" return [ {"role": "system", "content": "You are a helpful assistant."}, { From 56d4e0e699d9dd19596dcde0e17fce039afd0db6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 12:41:32 +0100 Subject: [PATCH 6/7] . --- sentry_sdk/ai/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 83cbb2db59..4b85bed704 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -147,7 +147,7 @@ def truncate_messages_by_size( """ Returns a truncated messages array, consisting of - the last message, with its content truncated to `max_single_message_chars` characters, - if the last message's size exceeds `max_bytes`; otherwise, + if the last message's size exceeds `max_bytes` bytes; otherwise, - the maximum number of messages, starting from the end of the `messages` array, whose total serialized size does not exceed `max_bytes` bytes. From 768a5b7f0b0566b1d112c756e8d70c74724d08b7 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 12 Nov 2025 12:43:09 +0100 Subject: [PATCH 7/7] . --- sentry_sdk/ai/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 4b85bed704..afa34f6831 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -145,10 +145,10 @@ def truncate_messages_by_size( ): # type: (List[Dict[str, Any]], int, int) -> Tuple[List[Dict[str, Any]], int] """ - Returns a truncated messages array, consisting of + Returns a truncated messages list, consisting of - the last message, with its content truncated to `max_single_message_chars` characters, if the last message's size exceeds `max_bytes` bytes; otherwise, - - the maximum number of messages, starting from the end of the `messages` array, whose total + - the maximum number of messages, starting from the end of the `messages` list, whose total serialized size does not exceed `max_bytes` bytes. In the single message case, the serialized message size may exceed `max_bytes`, because