Skip to content

Commit 9e68c96

Browse files
committed
add meta entries
1 parent f66a772 commit 9e68c96

File tree

4 files changed

+132
-75
lines changed

4 files changed

+132
-75
lines changed

sentry_sdk/ai/message_utils.py

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,9 @@
44
if TYPE_CHECKING:
55
from typing import Any, Dict, List, Optional
66

7-
try:
8-
from sentry_sdk.serializer import serialize
9-
except ImportError:
10-
# Fallback for cases where sentry_sdk isn't fully importable
11-
def serialize(obj, **kwargs):
12-
# type: (Any, **Any) -> Any
13-
return obj
7+
from sentry_sdk.serializer import serialize
8+
from sentry_sdk._types import AnnotatedValue
149

15-
16-
# Custom limit for gen_ai message serialization - 50% of MAX_EVENT_BYTES
17-
# to leave room for other event data while still being generous for messages
1810
MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB
1911

2012

@@ -50,21 +42,26 @@ def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
5042

5143

5244
def serialize_gen_ai_messages(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
53-
# type: (Optional[List[Dict[str, Any]]], int) -> Optional[str]
45+
# type: (Optional[Any], int) -> Optional[str]
5446
"""
5547
Serialize and truncate gen_ai messages for storage in spans.
5648
5749
This function handles the complete workflow of:
58-
1. Truncating messages to fit within size limits
59-
2. Serializing them using Sentry's serializer
50+
1. Truncating messages to fit within size limits (if not already done)
51+
2. Serializing them using Sentry's serializer (which processes AnnotatedValue for _meta)
6052
3. Converting to JSON string for storage
6153
62-
:param messages: List of message objects or None
54+
:param messages: List of message objects, AnnotatedValue, or None
6355
:param max_bytes: Maximum allowed size in bytes for the serialized messages
6456
:returns: JSON string of serialized messages or None if input was None/empty
6557
"""
6658
if not messages:
6759
return None
60+
61+
if isinstance(messages, AnnotatedValue):
62+
serialized_messages = serialize(messages, is_vars=False)
63+
return json.dumps(serialized_messages, separators=(",", ":"))
64+
6865
truncated_messages = truncate_messages_by_size(messages, max_bytes)
6966
if not truncated_messages:
7067
return None
@@ -96,44 +93,31 @@ def get_messages_metadata(original_messages, truncated_messages):
9693

9794

9895
def truncate_and_serialize_messages(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
99-
# type: (Optional[List[Dict[str, Any]]], int) -> Dict[str, Any]
96+
# type: (Optional[List[Dict[str, Any]]], int) -> Any
10097
"""
101-
One-stop function for gen_ai integrations to truncate and serialize messages.
98+
Truncate messages and return AnnotatedValue for automatic _meta creation.
10299
103-
This is the main function that gen_ai integrations should use. It handles the
104-
complete workflow and returns both the serialized data and metadata.
105-
106-
Example usage:
107-
from sentry_sdk.ai.message_utils import truncate_and_serialize_messages
108-
109-
result = truncate_and_serialize_messages(messages)
110-
if result['serialized_data']:
111-
span.set_data('gen_ai.request.messages', result['serialized_data'])
112-
if result['metadata']['was_truncated']:
113-
# Log warning about truncation if desired
114-
pass
100+
This function handles truncation and returns the truncated messages wrapped in an
101+
AnnotatedValue (when truncation occurs) so that Sentry's serializer can automatically
102+
create the appropriate _meta structure.
115103
116104
:param messages: List of message objects or None
117105
:param max_bytes: Maximum allowed size in bytes for the serialized messages
118-
:returns: Dictionary containing 'serialized_data', 'metadata', and 'original_size'
106+
:returns: List of messages, AnnotatedValue (if truncated), or None
119107
"""
120108
if not messages:
121-
return {
122-
"serialized_data": None,
123-
"metadata": get_messages_metadata([], []),
124-
"original_size": 0,
125-
}
126-
127-
original_serialized = serialize(messages, is_vars=False)
128-
original_json = json.dumps(original_serialized, separators=(",", ":"))
129-
original_size = len(original_json.encode("utf-8"))
109+
return None
130110

131111
truncated_messages = truncate_messages_by_size(messages, max_bytes)
132-
serialized_data = serialize_gen_ai_messages(truncated_messages, max_bytes)
133-
metadata = get_messages_metadata(messages, truncated_messages)
112+
if not truncated_messages:
113+
return None
134114

135-
return {
136-
"serialized_data": serialized_data,
137-
"metadata": metadata,
138-
"original_size": original_size,
139-
}
115+
original_count = len(messages)
116+
truncated_count = len(truncated_messages)
117+
118+
if original_count != truncated_count:
119+
return AnnotatedValue(
120+
value=serialize_gen_ai_messages(truncated_messages),
121+
metadata={"len": original_count},
122+
)
123+
return truncated_messages

sentry_sdk/integrations/langchain.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,9 @@ def on_llm_start(
210210
_set_tools_on_span(span, all_params.get("tools"))
211211

212212
if should_send_default_pii() and self.include_prompts:
213-
result = truncate_and_serialize_messages(prompts)
214-
if result["serialized_data"]:
215-
span.set_data(
216-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
217-
)
213+
messages_data = truncate_and_serialize_messages(prompts)
214+
if messages_data is not None:
215+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
218216

219217
def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs):
220218
# type: (SentryLangchainCallback, Dict[str, Any], List[List[BaseMessage]], UUID, Any) -> Any
@@ -265,11 +263,9 @@ def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs):
265263
normalized_messages.append(
266264
self._normalize_langchain_message(message)
267265
)
268-
result = truncate_and_serialize_messages(normalized_messages)
269-
if result["serialized_data"]:
270-
span.set_data(
271-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
272-
)
266+
messages_data = truncate_and_serialize_messages(normalized_messages)
267+
if messages_data is not None:
268+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
273269

274270
def on_chat_model_end(self, response, *, run_id, **kwargs):
275271
# type: (SentryLangchainCallback, LLMResult, UUID, Any) -> Any
@@ -742,11 +738,9 @@ def new_invoke(self, *args, **kwargs):
742738
and should_send_default_pii()
743739
and integration.include_prompts
744740
):
745-
result = truncate_and_serialize_messages([input])
746-
if result["serialized_data"]:
747-
span.set_data(
748-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
749-
)
741+
messages_data = truncate_and_serialize_messages([input])
742+
if messages_data is not None:
743+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
750744

751745
output = result.get("output")
752746
if (
@@ -795,11 +789,9 @@ def new_stream(self, *args, **kwargs):
795789
and should_send_default_pii()
796790
and integration.include_prompts
797791
):
798-
result = truncate_and_serialize_messages([input])
799-
if result["serialized_data"]:
800-
span.set_data(
801-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
802-
)
792+
messages_data = truncate_and_serialize_messages([input])
793+
if messages_data is not None:
794+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
803795

804796
# Run the agent
805797
result = f(self, *args, **kwargs)

sentry_sdk/integrations/langgraph.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,9 @@ def new_invoke(self, *args, **kwargs):
181181
):
182182
input_messages = _parse_langgraph_messages(args[0])
183183
if input_messages:
184-
result = truncate_and_serialize_messages(input_messages)
185-
if result["serialized_data"]:
186-
span.set_data(
187-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
188-
)
184+
messages_data = truncate_and_serialize_messages(input_messages)
185+
if messages_data is not None:
186+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
189187

190188
result = f(self, *args, **kwargs)
191189

@@ -230,11 +228,9 @@ async def new_ainvoke(self, *args, **kwargs):
230228
):
231229
input_messages = _parse_langgraph_messages(args[0])
232230
if input_messages:
233-
result = truncate_and_serialize_messages(input_messages)
234-
if result["serialized_data"]:
235-
span.set_data(
236-
SPANDATA.GEN_AI_REQUEST_MESSAGES, result["serialized_data"]
237-
)
231+
messages_data = truncate_and_serialize_messages(input_messages)
232+
if messages_data is not None:
233+
span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
238234

239235
result = await f(self, *args, **kwargs)
240236

tests/test_ai_message_utils.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
get_messages_metadata,
99
truncate_and_serialize_messages,
1010
)
11+
from sentry_sdk._types import AnnotatedValue
12+
from sentry_sdk.serializer import serialize
1113

1214

1315
@pytest.fixture
@@ -407,3 +409,86 @@ def test_truncation_keeps_most_recent(self):
407409
assert (
408410
"Message 9" in last_kept_content or "Message 8" in last_kept_content
409411
)
412+
413+
414+
class TestMetaSupport:
415+
"""Test that _meta entries are created correctly when truncation occurs"""
416+
417+
def test_annotated_value_returned_on_truncation(self, large_messages):
418+
"""Test that truncate_and_serialize_messages returns AnnotatedValue when truncation occurs"""
419+
# Force truncation with a limit that will keep at least one message
420+
# Each large message is ~30KB, so 50KB should keep 1-2 messages but force truncation
421+
small_limit = 50_000 # 50KB to force truncation but keep some messages
422+
result = truncate_and_serialize_messages(large_messages, max_bytes=small_limit)
423+
424+
# Should return an AnnotatedValue when truncation occurs
425+
assert isinstance(result, AnnotatedValue)
426+
assert result.metadata == {"len": len(large_messages)}
427+
428+
# The value should be the truncated messages
429+
assert isinstance(result.value, list)
430+
assert len(result.value) < len(large_messages)
431+
432+
def test_no_annotated_value_when_no_truncation(self, sample_messages):
433+
"""Test that truncate_and_serialize_messages returns plain list when no truncation occurs"""
434+
result = truncate_and_serialize_messages(sample_messages)
435+
436+
# Should return plain list when no truncation occurs
437+
assert not isinstance(result, AnnotatedValue)
438+
assert isinstance(result, list)
439+
assert len(result) == len(sample_messages)
440+
assert result == sample_messages
441+
442+
def test_meta_structure_in_serialized_output(self, large_messages):
443+
"""Test that _meta structure is created correctly in serialized output"""
444+
# Force truncation with a limit that will keep at least one message
445+
small_limit = 50_000 # 50KB to force truncation but keep some messages
446+
annotated_messages = truncate_and_serialize_messages(
447+
large_messages, max_bytes=small_limit
448+
)
449+
450+
# Simulate how the serializer would process this (like it does in actual span data)
451+
test_data = {"gen_ai": {"request": {"messages": annotated_messages}}}
452+
453+
# Serialize using Sentry's serializer (which processes AnnotatedValue)
454+
serialized = serialize(test_data, is_vars=False)
455+
456+
# Check that _meta structure was created
457+
assert "_meta" in serialized
458+
assert "gen_ai" in serialized["_meta"]
459+
assert "request" in serialized["_meta"]["gen_ai"]
460+
assert "messages" in serialized["_meta"]["gen_ai"]["request"]
461+
assert serialized["_meta"]["gen_ai"]["request"]["messages"][""] == {
462+
"len": len(large_messages)
463+
}
464+
465+
# Check that the actual data is still there
466+
assert "gen_ai" in serialized
467+
assert "request" in serialized["gen_ai"]
468+
assert "messages" in serialized["gen_ai"]["request"]
469+
assert isinstance(serialized["gen_ai"]["request"]["messages"], list)
470+
assert len(serialized["gen_ai"]["request"]["messages"]) < len(large_messages)
471+
472+
def test_serialize_gen_ai_messages_handles_annotated_value(self, large_messages):
473+
"""Test that serialize_gen_ai_messages handles AnnotatedValue input correctly"""
474+
# Create an AnnotatedValue manually
475+
truncated = large_messages[:2] # Keep only first 2 messages
476+
annotated = AnnotatedValue(
477+
value=truncated, metadata={"len": len(large_messages)}
478+
)
479+
480+
# serialize_gen_ai_messages should handle it
481+
result = serialize_gen_ai_messages(annotated)
482+
483+
assert result is not None
484+
parsed = json.loads(result)
485+
assert isinstance(parsed, list)
486+
assert len(parsed) == 2 # Only 2 messages kept
487+
488+
def test_empty_messages_no_annotated_value(self):
489+
"""Test that empty messages don't create AnnotatedValue"""
490+
result = truncate_and_serialize_messages([])
491+
assert result is None
492+
493+
result = truncate_and_serialize_messages(None)
494+
assert result is None

0 commit comments

Comments
 (0)