1111)
1212from sentry_sdk .ai .monitoring import ai_track
1313from sentry_sdk .ai .utils import (
14+ MAX_GEN_AI_MESSAGE_BYTES ,
1415 MAX_SINGLE_MESSAGE_CONTENT_CHARS ,
1516 set_data_normalized ,
1617 truncate_and_annotate_messages ,
18+ truncate_messages_by_size ,
19+ _find_truncation_index ,
1720 parse_data_uri ,
1821 redact_blob_message_parts ,
1922 get_modality_from_mime_type ,
@@ -219,8 +222,105 @@ def large_messages():
219222 ]
220223
221224
225+ class TestTruncateMessagesBySize :
226+ def test_no_truncation_needed (self , sample_messages ):
227+ """Test that messages under the limit are not truncated"""
228+ result , truncation_index = truncate_messages_by_size (
229+ sample_messages , max_bytes = MAX_GEN_AI_MESSAGE_BYTES
230+ )
231+ assert len (result ) == len (sample_messages )
232+ assert result == sample_messages
233+ assert truncation_index == 0
234+
235+ def test_truncation_removes_oldest_first (self , large_messages ):
236+ """Test that oldest messages are removed first during truncation"""
237+ small_limit = 3000
238+ result , truncation_index = truncate_messages_by_size (
239+ large_messages , max_bytes = small_limit
240+ )
241+ assert len (result ) < len (large_messages )
242+
243+ assert result [- 1 ] == large_messages [- 1 ]
244+ assert truncation_index == len (large_messages ) - len (result )
245+
246+ def test_empty_messages_list (self ):
247+ """Test handling of empty messages list"""
248+ result , truncation_index = truncate_messages_by_size (
249+ [], max_bytes = MAX_GEN_AI_MESSAGE_BYTES // 500
250+ )
251+ assert result == []
252+ assert truncation_index == 0
253+
254+ def test_find_truncation_index (
255+ self ,
256+ ):
257+ """Test that the truncation index is found correctly"""
258+ # when represented in JSON, these are each 7 bytes long
259+ messages = ["A" * 5 , "B" * 5 , "C" * 5 , "D" * 5 , "E" * 5 ]
260+ truncation_index = _find_truncation_index (messages , 20 )
261+ assert truncation_index == 3
262+ assert messages [truncation_index :] == ["D" * 5 , "E" * 5 ]
263+
264+ messages = ["A" * 5 , "B" * 5 , "C" * 5 , "D" * 5 , "E" * 5 ]
265+ truncation_index = _find_truncation_index (messages , 40 )
266+ assert truncation_index == 0
267+ assert messages [truncation_index :] == [
268+ "A" * 5 ,
269+ "B" * 5 ,
270+ "C" * 5 ,
271+ "D" * 5 ,
272+ "E" * 5 ,
273+ ]
274+
275+ def test_progressive_truncation (self , large_messages ):
276+ """Test that truncation works progressively with different limits"""
277+ limits = [
278+ MAX_GEN_AI_MESSAGE_BYTES // 5 ,
279+ MAX_GEN_AI_MESSAGE_BYTES // 10 ,
280+ MAX_GEN_AI_MESSAGE_BYTES // 25 ,
281+ MAX_GEN_AI_MESSAGE_BYTES // 100 ,
282+ MAX_GEN_AI_MESSAGE_BYTES // 500 ,
283+ ]
284+ prev_count = len (large_messages )
285+
286+ for limit in limits :
287+ result = truncate_messages_by_size (large_messages , max_bytes = limit )
288+ current_count = len (result )
289+
290+ assert current_count <= prev_count
291+ assert current_count >= 1
292+ prev_count = current_count
293+
294+ def test_single_message_truncation (self ):
295+ large_content = "This is a very long message. " * 10_000
296+
297+ messages = [
298+ {"role" : "system" , "content" : "You are a helpful assistant." },
299+ {"role" : "user" , "content" : large_content },
300+ ]
301+
302+ result , truncation_index = truncate_messages_by_size (
303+ messages , max_single_message_chars = MAX_SINGLE_MESSAGE_CONTENT_CHARS
304+ )
305+
306+ assert len (result ) == 1
307+ assert (
308+ len (result [0 ]["content" ].rstrip ("..." )) <= MAX_SINGLE_MESSAGE_CONTENT_CHARS
309+ )
310+
311+ # If the last message is too large, the system message is not present
312+ system_msgs = [m for m in result if m .get ("role" ) == "system" ]
313+ assert len (system_msgs ) == 0
314+
315+ # Confirm the user message is truncated with '...'
316+ user_msgs = [m for m in result if m .get ("role" ) == "user" ]
317+ assert len (user_msgs ) == 1
318+ assert user_msgs [0 ]["content" ].endswith ("..." )
319+ assert len (user_msgs [0 ]["content" ]) < len (large_content )
320+
321+
222322class TestTruncateAndAnnotateMessages :
223- def test_truncation_sets_metadata_on_scope (self , large_messages ):
323+ def test_no_truncation_returns_list (self , sample_messages ):
224324 class MockSpan :
225325 def __init__ (self ):
226326 self .span_id = "test_span_id"
@@ -233,20 +333,17 @@ class MockScope:
233333 def __init__ (self ):
234334 self ._gen_ai_original_message_count = {}
235335
236- small_limit = 3000
237336 span = MockSpan ()
238337 scope = MockScope ()
239- original_count = len (large_messages )
240- result = truncate_and_annotate_messages (
241- large_messages , span , scope , max_single_message_chars = small_limit
242- )
338+ result = truncate_and_annotate_messages (sample_messages , span , scope )
243339
244340 assert isinstance (result , list )
245341 assert not isinstance (result , AnnotatedValue )
246- assert len (result ) < len (large_messages )
247- assert scope ._gen_ai_original_message_count [span .span_id ] == original_count
342+ assert len (result ) == len (sample_messages )
343+ assert result == sample_messages
344+ assert span .span_id not in scope ._gen_ai_original_message_count
248345
249- def test_scope_tracks_original_message_count (self , large_messages ):
346+ def test_truncation_sets_metadata_on_scope (self , large_messages ):
250347 class MockSpan :
251348 def __init__ (self ):
252349 self .span_id = "test_span_id"
@@ -260,18 +357,19 @@ def __init__(self):
260357 self ._gen_ai_original_message_count = {}
261358
262359 small_limit = 3000
263- original_count = len (large_messages )
264360 span = MockSpan ()
265361 scope = MockScope ()
266-
362+ original_count = len ( large_messages )
267363 result = truncate_and_annotate_messages (
268- large_messages , span , scope , max_single_message_chars = small_limit
364+ large_messages , span , scope , max_bytes = small_limit
269365 )
270366
367+ assert isinstance (result , list )
368+ assert not isinstance (result , AnnotatedValue )
369+ assert len (result ) < len (large_messages )
271370 assert scope ._gen_ai_original_message_count [span .span_id ] == original_count
272- assert len (result ) == 1
273371
274- def test_empty_messages_returns_none (self ):
372+ def test_scope_tracks_original_message_count (self , large_messages ):
275373 class MockSpan :
276374 def __init__ (self ):
277375 self .span_id = "test_span_id"
@@ -284,15 +382,19 @@ class MockScope:
284382 def __init__ (self ):
285383 self ._gen_ai_original_message_count = {}
286384
385+ small_limit = 3000
386+ original_count = len (large_messages )
287387 span = MockSpan ()
288388 scope = MockScope ()
289- result = truncate_and_annotate_messages ([], span , scope )
290- assert result is None
291389
292- result = truncate_and_annotate_messages (None , span , scope )
293- assert result is None
390+ result = truncate_and_annotate_messages (
391+ large_messages , span , scope , max_bytes = small_limit
392+ )
393+
394+ assert scope ._gen_ai_original_message_count [span .span_id ] == original_count
395+ assert len (result ) == 1
294396
295- def test_single_message_truncation (self , large_messages ):
397+ def test_empty_messages_returns_none (self ):
296398 class MockSpan :
297399 def __init__ (self ):
298400 self .span_id = "test_span_id"
@@ -305,33 +407,13 @@ class MockScope:
305407 def __init__ (self ):
306408 self ._gen_ai_original_message_count = {}
307409
308- large_content = "This is a very long message. " * 10_000
309-
310- messages = [
311- {"role" : "system" , "content" : "You are a helpful assistant." },
312- {"role" : "user" , "content" : large_content },
313- ]
314-
315410 span = MockSpan ()
316411 scope = MockScope ()
317- result = truncate_and_annotate_messages (
318- messages ,
319- span ,
320- scope ,
321- max_single_message_chars = MAX_SINGLE_MESSAGE_CONTENT_CHARS ,
322- )
323- assert result is not None
324-
325- assert len (result ) == 1
326- assert (
327- len (result [0 ]["content" ].rstrip ("..." )) <= MAX_SINGLE_MESSAGE_CONTENT_CHARS
328- )
412+ result = truncate_and_annotate_messages ([], span , scope )
413+ assert result is None
329414
330- # Confirm the user message is truncated with '...'
331- user_msgs = [m for m in result if m .get ("role" ) == "user" ]
332- assert len (user_msgs ) == 1
333- assert user_msgs [0 ]["content" ].endswith ("..." )
334- assert len (user_msgs [0 ]["content" ]) < len (large_content )
415+ result = truncate_and_annotate_messages (None , span , scope )
416+ assert result is None
335417
336418 def test_truncated_messages_newest_first (self , large_messages ):
337419 class MockSpan :
@@ -350,7 +432,7 @@ def __init__(self):
350432 span = MockSpan ()
351433 scope = MockScope ()
352434 result = truncate_and_annotate_messages (
353- large_messages , span , scope , max_single_message_chars = small_limit
435+ large_messages , span , scope , max_bytes = small_limit
354436 )
355437
356438 assert isinstance (result , list )
@@ -418,12 +500,15 @@ class MockScope:
418500 def __init__ (self ):
419501 self ._gen_ai_original_message_count = {}
420502
503+ small_limit = 3000
421504 span = MockSpan ()
422505 scope = MockScope ()
423506 original_count = len (large_messages )
424507
425508 # Simulate what integrations do
426- truncated_messages = truncate_and_annotate_messages (large_messages , span , scope )
509+ truncated_messages = truncate_and_annotate_messages (
510+ large_messages , span , scope , max_bytes = small_limit
511+ )
427512 span .set_data (SPANDATA .GEN_AI_REQUEST_MESSAGES , truncated_messages )
428513
429514 # Verify metadata was set on scope
@@ -472,11 +557,14 @@ class MockScope:
472557 def __init__ (self ):
473558 self ._gen_ai_original_message_count = {}
474559
560+ small_limit = 3000
475561 span = MockSpan ()
476562 scope = MockScope ()
477563 original_message_count = len (large_messages )
478564
479- truncated_messages = truncate_and_annotate_messages (large_messages , span , scope )
565+ truncated_messages = truncate_and_annotate_messages (
566+ large_messages , span , scope , max_bytes = small_limit
567+ )
480568
481569 assert len (truncated_messages ) < original_message_count
482570
0 commit comments