From 749e40915abb79597fe298c8190d7981bd30347d Mon Sep 17 00:00:00 2001 From: Saurabh Misra Date: Wed, 15 Oct 2025 00:50:56 -0700 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Speed=20up=20functio?= =?UTF-8?q?n=20`=5Fget=5Fdb=5Fspan=5Fdescription`=20(#4924)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi all, I am building Codeflash.ai which is an automated performance optimizer for Python codebases. I tried optimizing sentry and found a bunch of great optimizations that I would like to contribute. Would love to collaborate with your team to get them reviewed and merged. Let me know what's the best way to get in touch. #### 📄 44% (0.44x) speedup for ***`_get_db_span_description` in `sentry_sdk/integrations/redis/modules/queries.py`*** ⏱️ Runtime : **`586 microseconds`** **→** **`408 microseconds`** (best of `269` runs) #### 📝 Explanation and details The optimization achieves a **43% speedup** by eliminating redundant function calls inside the loop in `_get_safe_command()`. **Key optimizations applied:** 1. **Cached `should_send_default_pii()` call**: The original code called this function inside the loop for every non-key argument (up to 146 times in profiling). The optimized version calls it once before the loop and stores the result in `send_default_pii`, reducing expensive function calls from O(n) to O(1). 2. **Pre-computed `name.lower()`**: The original code computed `name.lower()` inside the loop for every argument (204 times in profiling). The optimized version computes it once before the loop and reuses the `name_low` variable. **Performance impact from profiling:** - The `should_send_default_pii()` calls dropped from 1.40ms (65.2% of total time) to 625μs (45.9% of total time) - The `name.lower()` calls were eliminated from the loop entirely, removing 99ms of redundant computation - Overall `_get_safe_command` execution time improved from 2.14ms to 1.36ms (36% faster) **Test case patterns where this optimization excels:** - **Multiple arguments**: Commands with many arguments see dramatic improvements (up to 262% faster for large arg lists) - **Large-scale operations**: Tests with 1000+ arguments show 171-223% speedups - **Frequent Redis commands**: Any command processing multiple values benefits significantly The optimization is most effective when processing Redis commands with multiple arguments, which is common in batch operations and complex data manipulations. ✅ **Correctness verification report:** | Test | Status | | --------------------------- | ----------------- | | ⚙️ Existing Unit Tests | 🔘 **None Found** | | 🌀 Generated Regression Tests | ✅ **48 Passed** | | ⏪ Replay Tests | 🔘 **None Found** | | 🔎 Concolic Coverage Tests | 🔘 **None Found** | |📊 Tests Coverage | 100.0% |
🌀 Generated Regression Tests and Runtime ```python import pytest from sentry_sdk.integrations.redis.modules.queries import \ _get_db_span_description _MAX_NUM_ARGS = 10 # Dummy RedisIntegration class for testing class RedisIntegration: def __init__(self, max_data_size=None): self.max_data_size = max_data_size # Dummy should_send_default_pii function for testing _send_pii = False from sentry_sdk.integrations.redis.modules.queries import \ _get_db_span_description # --- Basic Test Cases --- def test_basic_no_args(): """Test command with no arguments.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "PING", ()); desc = codeflash_output # 2.55μs -> 7.76μs (67.2% slower) def test_basic_single_arg_pii_false(): """Test command with one argument, PII off.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("mykey",)); desc = codeflash_output # 3.62μs -> 7.86μs (54.0% slower) def test_basic_single_arg_pii_true(): """Test command with one argument, PII on.""" global _send_pii _send_pii = True integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("mykey",)); desc = codeflash_output # 3.28μs -> 7.40μs (55.7% slower) def test_basic_multiple_args_pii_false(): """Test command with multiple args, PII off.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("mykey", "value1", "value2")); desc = codeflash_output # 12.6μs -> 8.24μs (52.8% faster) def test_basic_multiple_args_pii_true(): """Test command with multiple args, PII on.""" global _send_pii _send_pii = True integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("mykey", "value1", "value2")); desc = codeflash_output # 9.92μs -> 8.47μs (17.0% faster) def test_basic_sensitive_command(): """Test sensitive command: should always filter after command name.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "SET", ("mykey", "secret")); desc = codeflash_output # 7.96μs -> 7.56μs (5.33% faster) def test_basic_sensitive_command_case_insensitive(): """Test sensitive command with different casing.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "set", ("mykey", "secret")); desc = codeflash_output # 7.77μs -> 7.84μs (0.881% slower) def test_basic_max_num_args(): """Test that args beyond _MAX_NUM_ARGS are ignored.""" integration = RedisIntegration() args = tuple(f"arg{i}" for i in range(_MAX_NUM_ARGS + 2)) codeflash_output = _get_db_span_description(integration, "GET", args); desc = codeflash_output # 28.0μs -> 9.43μs (197% faster) # Only up to _MAX_NUM_ARGS+1 args are processed (the first arg is key) expected = "GET 'arg0'" + " [Filtered]" * _MAX_NUM_ARGS # --- Edge Test Cases --- def test_edge_empty_command_name(): """Test with empty command name.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "", ("key",)); desc = codeflash_output # 3.22μs -> 7.46μs (56.9% slower) def test_edge_empty_args(): """Test with empty args tuple.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "DEL", ()); desc = codeflash_output # 2.09μs -> 6.73μs (69.0% slower) def test_edge_none_arg(): """Test with None argument.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", (None,)); desc = codeflash_output # 3.37μs -> 7.57μs (55.5% slower) def test_edge_mixed_types_args(): """Test with mixed argument types.""" integration = RedisIntegration() args = ("key", 123, 45.6, True, None, ["a", "b"], {"x": 1}) codeflash_output = _get_db_span_description(integration, "GET", args); desc = codeflash_output # 19.9μs -> 8.46μs (136% faster) def test_edge_sensitive_command_with_pii_true(): """Sensitive commands should always filter, even if PII is on.""" global _send_pii _send_pii = True integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "AUTH", ("user", "pass")); desc = codeflash_output # 3.40μs -> 7.50μs (54.7% slower) def test_edge_max_data_size_truncation(): """Test truncation when description exceeds max_data_size.""" integration = RedisIntegration(max_data_size=15) codeflash_output = _get_db_span_description(integration, "GET", ("verylongkeyname", "value")); desc = codeflash_output # 9.20μs -> 8.72μs (5.57% faster) # "GET 'verylongkeyname' [Filtered]" is longer than 15 # Truncate to 15-len("...") = 12, then add "..." expected = "GET 'verylo..." def test_edge_max_data_size_exact_length(): """Test truncation when description is exactly max_data_size.""" integration = RedisIntegration(max_data_size=23) codeflash_output = _get_db_span_description(integration, "GET", ("shortkey",)); desc = codeflash_output # 3.33μs -> 7.63μs (56.4% slower) def test_edge_max_data_size_less_than_ellipsis(): """Test when max_data_size is less than length of ellipsis.""" integration = RedisIntegration(max_data_size=2) codeflash_output = _get_db_span_description(integration, "GET", ("key",)); desc = codeflash_output # 4.07μs -> 8.65μs (52.9% slower) def test_edge_args_are_empty_strings(): """Test when args are empty strings.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("", "")); desc = codeflash_output # 8.52μs -> 7.74μs (10.1% faster) def test_edge_command_name_is_space(): """Test when command name is a space.""" integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, " ", ("key",)); desc = codeflash_output # 3.09μs -> 7.34μs (57.9% slower) # --- Large Scale Test Cases --- def test_large_many_args_pii_false(): """Test with a large number of arguments, PII off.""" integration = RedisIntegration() args = tuple(f"arg{i}" for i in range(1000)) codeflash_output = _get_db_span_description(integration, "GET", args); desc = codeflash_output # 32.3μs -> 10.3μs (213% faster) # Only first arg shown, rest are filtered, up to _MAX_NUM_ARGS expected = "GET 'arg0'" + " [Filtered]" * min(len(args)-1, _MAX_NUM_ARGS) def test_large_many_args_pii_true(): """Test with a large number of arguments, PII on.""" global _send_pii _send_pii = True integration = RedisIntegration() args = tuple(f"arg{i}" for i in range(1000)) # Only up to _MAX_NUM_ARGS are processed expected = "GET " + " ".join([repr(f"arg{i}") for i in range(_MAX_NUM_ARGS+1)]) codeflash_output = _get_db_span_description(integration, "GET", args); desc = codeflash_output # 28.1μs -> 9.55μs (194% faster) def test_large_long_command_name_and_args(): """Test with very long command name and args.""" integration = RedisIntegration() cmd = "LONGCOMMAND" * 10 args = tuple("X"*100 for _ in range(_MAX_NUM_ARGS+1)) expected = cmd + " " + " ".join([repr("X"*100) if i == 0 else "[Filtered]" for i in range(_MAX_NUM_ARGS+1)]) codeflash_output = _get_db_span_description(integration, cmd, args); desc = codeflash_output # 34.2μs -> 9.45μs (262% faster) def test_large_truncation(): """Test truncation with very large description.""" integration = RedisIntegration(max_data_size=50) args = tuple("X"*20 for _ in range(_MAX_NUM_ARGS+1)) codeflash_output = _get_db_span_description(integration, "GET", args); desc = codeflash_output # 28.3μs -> 10.0μs (182% faster) def test_large_sensitive_command(): """Test large sensitive command, all args filtered.""" integration = RedisIntegration() args = tuple(f"secret{i}" for i in range(1000)) codeflash_output = _get_db_span_description(integration, "SET", args); desc = codeflash_output # 28.0μs -> 10.1μs (178% faster) # Only up to _MAX_NUM_ARGS+1 args are processed, all filtered expected = "SET" + " [Filtered]" * (_MAX_NUM_ARGS+1) # codeflash_output is used to check that the output of the original code is the same as that of the optimized code. #------------------------------------------------ import pytest # used for our unit tests from sentry_sdk.integrations.redis.modules.queries import \ _get_db_span_description _MAX_NUM_ARGS = 10 # Minimal RedisIntegration stub for testing class RedisIntegration: def __init__(self, max_data_size=None): self.max_data_size = max_data_size # Minimal Scope and client stub for should_send_default_pii class ClientStub: def __init__(self, send_pii): self._send_pii = send_pii def should_send_default_pii(self): return self._send_pii class Scope: _client = ClientStub(send_pii=False) @classmethod def get_client(cls): return cls._client def should_send_default_pii(): return Scope.get_client().should_send_default_pii() from sentry_sdk.integrations.redis.modules.queries import \ _get_db_span_description # --- Begin: Unit Tests --- # 1. Basic Test Cases def test_basic_single_arg_no_pii(): # Test a simple command with one argument, PII disabled Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("mykey",)); result = codeflash_output # 3.46μs -> 7.84μs (55.9% slower) def test_basic_multiple_args_no_pii(): # Test a command with multiple arguments, PII disabled Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "SET", ("mykey", "myvalue")); result = codeflash_output # 8.35μs -> 8.05μs (3.70% faster) def test_basic_multiple_args_with_pii(): # Test a command with multiple arguments, PII enabled Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "SET", ("mykey", "myvalue")); result = codeflash_output # 7.97μs -> 7.63μs (4.39% faster) def test_basic_sensitive_command(): # Test a sensitive command, should always be filtered Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "AUTH", ("user", "password")); result = codeflash_output # 3.40μs -> 7.46μs (54.4% slower) def test_basic_no_args(): # Test a command with no arguments Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "PING", ()); result = codeflash_output # 2.16μs -> 6.63μs (67.4% slower) # 2. Edge Test Cases def test_edge_max_num_args(): # Test with more than _MAX_NUM_ARGS arguments, should truncate at _MAX_NUM_ARGS Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = tuple(f"arg{i}" for i in range(_MAX_NUM_ARGS + 2)) codeflash_output = _get_db_span_description(integration, "SET", args); result = codeflash_output # 32.4μs -> 9.05μs (258% faster) # Only up to _MAX_NUM_ARGS should be included expected = "SET " + " ".join( [repr(args[0])] + [repr(arg) for arg in args[1:_MAX_NUM_ARGS+1]] ) def test_edge_empty_string_key(): # Test with an empty string as key Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", ("",)); result = codeflash_output # 3.42μs -> 7.51μs (54.5% slower) def test_edge_none_key(): # Test with None as key Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", (None,)); result = codeflash_output # 3.25μs -> 7.42μs (56.2% slower) def test_edge_non_string_key(): # Test with integer as key Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", (12345,)); result = codeflash_output # 3.24μs -> 7.62μs (57.5% slower) def test_edge_sensitive_command_case_insensitive(): # Test sensitive command with mixed case Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "AuTh", ("user", "password")); result = codeflash_output # 3.57μs -> 7.72μs (53.8% slower) def test_edge_truncation_exact(): # Test truncation where description is exactly max_data_size Scope._client = ClientStub(send_pii=True) integration = RedisIntegration(max_data_size=13) codeflash_output = _get_db_span_description(integration, "GET", ("mykey",)); result = codeflash_output # 3.61μs -> 8.05μs (55.1% slower) def test_edge_truncation_needed(): # Test truncation where description exceeds max_data_size Scope._client = ClientStub(send_pii=True) integration = RedisIntegration(max_data_size=10) codeflash_output = _get_db_span_description(integration, "GET", ("mykey",)); result = codeflash_output # 4.32μs -> 7.96μs (45.8% slower) def test_edge_truncation_with_filtered(): # Truncation with filtered data Scope._client = ClientStub(send_pii=False) integration = RedisIntegration(max_data_size=10) codeflash_output = _get_db_span_description(integration, "SET", ("mykey", "myvalue")); result = codeflash_output # 10.3μs -> 8.92μs (15.7% faster) def test_edge_args_are_bytes(): # Test arguments are bytes Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "GET", (b"mykey",)); result = codeflash_output # 3.42μs -> 7.54μs (54.7% slower) def test_edge_args_are_mixed_types(): # Test arguments are mixed types Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = ("key", 123, None, b"bytes") codeflash_output = _get_db_span_description(integration, "SET", args); result = codeflash_output # 13.7μs -> 8.31μs (65.1% faster) expected = "SET 'key' 123 None b'bytes'" def test_edge_args_are_empty_tuple(): # Test arguments is empty tuple Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "PING", ()); result = codeflash_output # 2.14μs -> 6.67μs (67.9% slower) def test_edge_args_are_list(): # Test arguments as a list (should still work as sequence) Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() codeflash_output = _get_db_span_description(integration, "SET", ["key", "val"]); result = codeflash_output # 8.54μs -> 7.96μs (7.30% faster) def test_edge_args_are_dict(): # Test arguments as a dict (should treat as sequence of keys) Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = {"a": 1, "b": 2} codeflash_output = _get_db_span_description(integration, "SET", args); result = codeflash_output # 7.87μs -> 7.86μs (0.102% faster) def test_edge_args_are_long_string(): # Test argument is a very long string (truncation) Scope._client = ClientStub(send_pii=True) integration = RedisIntegration(max_data_size=20) long_str = "x" * 100 codeflash_output = _get_db_span_description(integration, "SET", (long_str,)); result = codeflash_output # 4.46μs -> 8.43μs (47.1% slower) # 3. Large Scale Test Cases def test_large_many_args_no_pii(): # Test with large number of arguments, PII disabled Scope._client = ClientStub(send_pii=False) integration = RedisIntegration() args = tuple(f"key{i}" for i in range(999)) codeflash_output = _get_db_span_description(integration, "MGET", args); result = codeflash_output # 28.6μs -> 10.6μs (171% faster) # Only first is shown, rest are filtered (up to _MAX_NUM_ARGS) expected = "MGET 'key0'" + " [Filtered]" * _MAX_NUM_ARGS def test_large_many_args_with_pii(): # Test with large number of arguments, PII enabled Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = tuple(f"key{i}" for i in range(999)) codeflash_output = _get_db_span_description(integration, "MGET", args); result = codeflash_output # 30.9μs -> 9.87μs (213% faster) # Only up to _MAX_NUM_ARGS are shown expected = "MGET " + " ".join([repr(arg) for arg in args[:_MAX_NUM_ARGS+1]]) def test_large_truncation(): # Test truncation with large description Scope._client = ClientStub(send_pii=True) integration = RedisIntegration(max_data_size=50) args = tuple("x" * 10 for _ in range(20)) codeflash_output = _get_db_span_description(integration, "MGET", args); result = codeflash_output # 31.0μs -> 10.4μs (198% faster) def test_large_sensitive_command(): # Test large sensitive command, should always be filtered Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = tuple("x" * 10 for _ in range(20)) codeflash_output = _get_db_span_description(integration, "AUTH", args); result = codeflash_output # 5.42μs -> 9.30μs (41.8% slower) def test_large_args_are_large_numbers(): # Test with large integer arguments Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = tuple(10**6 + i for i in range(_MAX_NUM_ARGS + 1)) codeflash_output = _get_db_span_description(integration, "MGET", args); result = codeflash_output # 27.6μs -> 9.38μs (194% faster) expected = "MGET " + " ".join([repr(arg) for arg in args[:_MAX_NUM_ARGS+1]]) def test_large_args_are_large_bytes(): # Test with large bytes arguments Scope._client = ClientStub(send_pii=True) integration = RedisIntegration() args = tuple(b"x" * 100 for _ in range(_MAX_NUM_ARGS + 1)) codeflash_output = _get_db_span_description(integration, "MGET", args); result = codeflash_output # 30.2μs -> 9.35μs (223% faster) expected = "MGET " + " ".join([repr(arg) for arg in args[:_MAX_NUM_ARGS+1]]) # codeflash_output is used to check that the output of the original code is the same as that of the optimized code. ```
To edit these changes `git checkout codeflash/optimize-_get_db_span_description-mg9vzvxu` and push. [![Codeflash](https://img.shields.io/badge/Optimized%20with-Codeflash-yellow?style=flat&color=%23ffc428&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgwIiBoZWlnaHQ9ImF1dG8iIHZpZXdCb3g9IjAgMCA0ODAgMjgwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4Ni43IDAuMzc4NDE4SDIwMS43NTFMNTAuOTAxIDE0OC45MTFIMTM1Ljg1MUwwLjk2MDkzOCAyODEuOTk5SDk1LjQzNTJMMjgyLjMyNCA4OS45NjE2SDE5Ni4zNDVMMjg2LjcgMC4zNzg0MThaIiBmaWxsPSIjRkZDMDQzIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzExLjYwNyAwLjM3ODkwNkwyNTguNTc4IDU0Ljk1MjZIMzc5LjU2N0w0MzIuMzM5IDAuMzc4OTA2SDMxMS42MDdaIiBmaWxsPSIjMEIwQTBBIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMzA5LjU0NyA4OS45NjAxTDI1Ni41MTggMTQ0LjI3NkgzNzcuNTA2TDQzMC4wMjEgODkuNzAyNkgzMDkuNTQ3Vjg5Ljk2MDFaIiBmaWxsPSIjMEIwQTBBIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMjQyLjg3MyAxNjQuNjZMMTg5Ljg0NCAyMTkuMjM0SDMxMC44MzNMMzYzLjM0NyAxNjQuNjZIMjQyLjg3M1oiIGZpbGw9IiMwQjBBMEEiLz4KPC9zdmc+Cg==)](https://codeflash.ai) Co-authored-by: codeflash-ai[bot] <148906541+codeflash-ai[bot]@users.noreply.github.com> --- sentry_sdk/integrations/redis/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py index cf230f6648..7bb73f3372 100644 --- a/sentry_sdk/integrations/redis/utils.py +++ b/sentry_sdk/integrations/redis/utils.py @@ -20,12 +20,13 @@ def _get_safe_command(name, args): # type: (str, Sequence[Any]) -> str command_parts = [name] + name_low = name.lower() + send_default_pii = should_send_default_pii() + for i, arg in enumerate(args): if i > _MAX_NUM_ARGS: break - name_low = name.lower() - if name_low in _COMMANDS_INCLUDING_SENSITIVE_DATA: command_parts.append(SENSITIVE_DATA_SUBSTITUTE) continue @@ -33,9 +34,8 @@ def _get_safe_command(name, args): arg_is_the_key = i == 0 if arg_is_the_key: command_parts.append(repr(arg)) - else: - if should_send_default_pii(): + if send_default_pii: command_parts.append(repr(arg)) else: command_parts.append(SENSITIVE_DATA_SUBSTITUTE) From a311e3b4d4f75e696c388352158c9a38a8718e21 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 15 Oct 2025 13:42:07 +0200 Subject: [PATCH 02/15] Generalize NOT_GIVEN check with omit for openai (#4926) ### Description openai uses `Omit` now instead of `NotGiven` https://github.com/openai/openai-python/commit/82602884b61ef2f407f4c5f4fcae7d07243897be #### Issues * resolves: #4923 * resolves: PY-1885 --- sentry_sdk/integrations/openai.py | 28 +++++++++++++++++++----- tests/integrations/openai/test_openai.py | 7 +++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index e9bd2efa23..19d7717b3c 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -1,4 +1,5 @@ from functools import wraps +from collections.abc import Iterable import sentry_sdk from sentry_sdk import consts @@ -17,14 +18,19 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator + from typing import Any, List, Optional, Callable, AsyncIterator, Iterator from sentry_sdk.tracing import Span try: try: - from openai import NOT_GIVEN + from openai import NotGiven except ImportError: - NOT_GIVEN = None + NotGiven = None + + try: + from openai import Omit + except ImportError: + Omit = None from openai.resources.chat.completions import Completions, AsyncCompletions from openai.resources import Embeddings, AsyncEmbeddings @@ -204,12 +210,12 @@ def _set_input_data(span, kwargs, operation, integration): for key, attribute in kwargs_keys_to_attributes.items(): value = kwargs.get(key) - if value is not NOT_GIVEN and value is not None: + if value is not None and _is_given(value): set_data_normalized(span, attribute, value) # Input attributes: Tools tools = kwargs.get("tools") - if tools is not NOT_GIVEN and tools is not None and len(tools) > 0: + if tools is not None and _is_given(tools) and len(tools) > 0: set_data_normalized( span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, safe_serialize(tools) ) @@ -689,3 +695,15 @@ async def _sentry_patched_responses_async(*args, **kwargs): return await _execute_async(f, *args, **kwargs) return _sentry_patched_responses_async + + +def _is_given(obj): + # type: (Any) -> bool + """ + Check for givenness safely across different openai versions. + """ + if NotGiven is not None and isinstance(obj, NotGiven): + return False + if Omit is not None and isinstance(obj, Omit): + return False + return True diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index 06e0a09fcf..276a1b4886 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -7,6 +7,11 @@ except ImportError: NOT_GIVEN = None +try: + from openai import omit +except ImportError: + omit = None + from openai import AsyncOpenAI, OpenAI, AsyncStream, Stream, OpenAIError from openai.types import CompletionUsage, CreateEmbeddingResponse, Embedding from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionChunk @@ -1424,7 +1429,7 @@ async def test_streaming_responses_api_async( ) @pytest.mark.parametrize( "tools", - [[], None, NOT_GIVEN], + [[], None, NOT_GIVEN, omit], ) def test_empty_tools_in_chat_completion(sentry_init, capture_events, tools): sentry_init( From 23411e57cfa78ce4b949d24c458a709ce8b902d7 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Wed, 15 Oct 2025 15:04:34 +0200 Subject: [PATCH 03/15] fix(litellm): Classify embeddings correctly (#4918) Check the `call_type` value to distinguish embeddings from chats. The `client` decorator sets `call_type` by introspecting the function name and wraps all of the top-level `litellm` functions. If users import from `litellm.llms`, embedding calls still may appear as chats, but the input callback we provide does not have enough information in that case. Closes https://github.com/getsentry/sentry-python/issues/4908 --- sentry_sdk/integrations/litellm.py | 8 ++++++-- tests/integrations/litellm/test_litellm.py | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/litellm.py b/sentry_sdk/integrations/litellm.py index 2582c2bc05..1f047b1c1d 100644 --- a/sentry_sdk/integrations/litellm.py +++ b/sentry_sdk/integrations/litellm.py @@ -48,8 +48,11 @@ def _input_callback(kwargs): model = full_model provider = "unknown" - messages = kwargs.get("messages", []) - operation = "chat" if messages else "embeddings" + call_type = kwargs.get("call_type", None) + if call_type == "embedding": + operation = "embeddings" + else: + operation = "chat" # Start a new span/transaction span = get_start_span_function()( @@ -71,6 +74,7 @@ def _input_callback(kwargs): set_data_normalized(span, SPANDATA.GEN_AI_OPERATION_NAME, operation) # Record messages if allowed + messages = kwargs.get("messages", []) if messages and should_send_default_pii() and integration.include_prompts: set_data_normalized( span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages, unpack=False diff --git a/tests/integrations/litellm/test_litellm.py b/tests/integrations/litellm/test_litellm.py index b600c32905..19ae206c85 100644 --- a/tests/integrations/litellm/test_litellm.py +++ b/tests/integrations/litellm/test_litellm.py @@ -208,14 +208,15 @@ def test_embeddings_create(sentry_init, capture_events): ) events = capture_events() + messages = [{"role": "user", "content": "Some text to test embeddings"}] mock_response = MockEmbeddingResponse() with start_transaction(name="litellm test"): - # For embeddings, messages would be empty kwargs = { "model": "text-embedding-ada-002", "input": "Hello!", - "messages": [], # Empty for embeddings + "messages": messages, + "call_type": "embedding", } _input_callback(kwargs) From 43258029347740c585cab8850d05452d5e2fb4bd Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 15 Oct 2025 15:17:07 +0200 Subject: [PATCH 04/15] Handle ValueError in scope resets (#4928) ### Description when async generators throw a `GeneratorExit` we end up with ``` ValueError: at 0x7f04ceb17340> was created in a different Context ``` so just catch that and rely on GC to cleanup the contextvar since we can't be smarter than that anyway for this case. #### Issues * resolves: #4925 * resolves: PY-1886 --- sentry_sdk/scope.py | 12 ++++++------ tests/test_scope.py | 14 ++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index c871e6a467..f9caf7e1d6 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1679,7 +1679,7 @@ def new_scope(): try: # restore original scope _current_scope.reset(token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) @@ -1717,7 +1717,7 @@ def use_scope(scope): try: # restore original scope _current_scope.reset(token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) @@ -1761,12 +1761,12 @@ def isolation_scope(): # restore original scopes try: _current_scope.reset(current_token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) try: _isolation_scope.reset(isolation_token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) @@ -1808,12 +1808,12 @@ def use_isolation_scope(isolation_scope): # restore original scopes try: _current_scope.reset(current_token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) try: _isolation_scope.reset(isolation_token) - except LookupError: + except (LookupError, ValueError): capture_internal_exception(sys.exc_info()) diff --git a/tests/test_scope.py b/tests/test_scope.py index e645d84234..68c93f3036 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -908,6 +908,7 @@ def test_last_event_id_cleared(sentry_init): @pytest.mark.tests_internal_exceptions +@pytest.mark.parametrize("error_cls", [LookupError, ValueError]) @pytest.mark.parametrize( "scope_manager", [ @@ -915,10 +916,10 @@ def test_last_event_id_cleared(sentry_init): use_scope, ], ) -def test_handle_lookup_error_on_token_reset_current_scope(scope_manager): +def test_handle_error_on_token_reset_current_scope(error_cls, scope_manager): with mock.patch("sentry_sdk.scope.capture_internal_exception") as mock_capture: with mock.patch("sentry_sdk.scope._current_scope") as mock_token_var: - mock_token_var.reset.side_effect = LookupError() + mock_token_var.reset.side_effect = error_cls() mock_token = mock.Mock() mock_token_var.set.return_value = mock_token @@ -932,13 +933,14 @@ def test_handle_lookup_error_on_token_reset_current_scope(scope_manager): pass except Exception: - pytest.fail("Context manager should handle LookupError gracefully") + pytest.fail(f"Context manager should handle {error_cls} gracefully") mock_capture.assert_called_once() mock_token_var.reset.assert_called_once_with(mock_token) @pytest.mark.tests_internal_exceptions +@pytest.mark.parametrize("error_cls", [LookupError, ValueError]) @pytest.mark.parametrize( "scope_manager", [ @@ -946,13 +948,13 @@ def test_handle_lookup_error_on_token_reset_current_scope(scope_manager): use_isolation_scope, ], ) -def test_handle_lookup_error_on_token_reset_isolation_scope(scope_manager): +def test_handle_error_on_token_reset_isolation_scope(error_cls, scope_manager): with mock.patch("sentry_sdk.scope.capture_internal_exception") as mock_capture: with mock.patch("sentry_sdk.scope._current_scope") as mock_current_scope: with mock.patch( "sentry_sdk.scope._isolation_scope" ) as mock_isolation_scope: - mock_isolation_scope.reset.side_effect = LookupError() + mock_isolation_scope.reset.side_effect = error_cls() mock_current_token = mock.Mock() mock_current_scope.set.return_value = mock_current_token @@ -965,7 +967,7 @@ def test_handle_lookup_error_on_token_reset_isolation_scope(scope_manager): pass except Exception: - pytest.fail("Context manager should handle LookupError gracefully") + pytest.fail(f"Context manager should handle {error_cls} gracefully") mock_capture.assert_called_once() mock_current_scope.reset.assert_called_once_with(mock_current_token) From d21fabd6fd22f38025df3258938b961c87c18a13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:48:40 +0000 Subject: [PATCH 05/15] =?UTF-8?q?ci:=20=F0=9F=A4=96=20Update=20test=20matr?= =?UTF-8?q?ix=20with=20new=20releases=20(10/16)=20(#4945)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update our test matrix with new releases of integrated frameworks and libraries. ## How it works - Scan PyPI for all supported releases of all frameworks we have a dedicated test suite for. - Pick a representative sample of releases to run our test suite against. We always test the latest and oldest supported version. - Update [tox.ini](https://github.com/getsentry/sentry-python/blob/master/tox.ini) with the new releases. ## Action required - If CI passes on this PR, it's safe to approve and merge. It means our integrations can handle new versions of frameworks that got pulled in. - If CI doesn't pass on this PR, this points to an incompatibility of either our integration or our test setup with a new version of a framework. - Check what the failures look like and either fix them, or update the [test config](https://github.com/getsentry/sentry-python/blob/master/scripts/populate_tox/config.py) and rerun [scripts/generate-test-files.sh](https://github.com/getsentry/sentry-python/blob/master/scripts/generate-test-files.sh). See [scripts/populate_tox/README.md](https://github.com/getsentry/sentry-python/blob/master/scripts/populate_tox/README.md) for what configuration options are available. _____________________ _🤖 This PR was automatically created using [a GitHub action](https://github.com/getsentry/sentry-python/blob/master/.github/workflows/update-tox.yml)._ Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/populate_tox/releases.jsonl | 16 +++++++-------- tox.ini | 32 ++++++++++++++--------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index 2ff66f2b18..537b3a64e8 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -26,7 +26,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.7", "version": "0.16.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.7", "version": "0.34.2", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.8", "version": "0.52.2", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.8", "version": "0.69.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "anthropic", "requires_python": ">=3.8", "version": "0.70.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.12.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.13.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: End Users/Desktop", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "apache-beam", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*", "version": "2.14.0", "yanked": false}} @@ -46,7 +46,7 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "boto3", "requires_python": "", "version": "1.12.49", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.6", "version": "1.20.54", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.7", "version": "1.28.85", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.9", "version": "1.40.50", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.9", "version": "1.40.53", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2.5", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": "", "version": "0.12.25", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": null, "version": "0.13.4", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Object Brokering", "Topic :: System :: Distributed Computing"], "name": "celery", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", "version": "4.4.7", "yanked": false}} @@ -55,10 +55,10 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8"], "name": "chalice", "requires_python": "", "version": "1.16.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "chalice", "requires_python": null, "version": "1.32.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: SQL", "Topic :: Database", "Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "clickhouse-driver", "requires_python": "<4,>=3.7", "version": "0.2.9", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.13.12", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.18.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.8", "version": "5.10.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.15.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.9", "version": "5.19.0", "yanked": false}} {"info": {"classifiers": ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "cohere", "requires_python": "<4.0,>=3.8", "version": "5.4.0", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "cohere", "requires_python": "<4.0,>=3.8", "version": "5.9.4", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: System :: Distributed Computing"], "name": "dramatiq", "requires_python": ">=3.9", "version": "1.18.0", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: System :: Distributed Computing"], "name": "dramatiq", "requires_python": ">=3.5", "version": "1.9.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: Jython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": "", "version": "1.4.1", "yanked": false}} @@ -72,7 +72,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.29.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.34.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.39.1", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.43.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.45.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.2.0b0", "yanked": false}} @@ -99,7 +99,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.28.1", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.32.6", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.35.3", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.9.0", "version": "1.0.0rc5", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.9.0", "version": "1.0.0rc6", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "langchain", "requires_python": "<4.0,>=3.8.1", "version": "0.1.20", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "langchain", "requires_python": "<4.0,>=3.8.1", "version": "0.2.17", "yanked": false}} {"info": {"classifiers": [], "name": "langchain", "requires_python": "<4.0,>=3.9", "version": "0.3.27", "yanked": false}} @@ -108,7 +108,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries"], "name": "launchdarkly-server-sdk", "requires_python": ">=3.9", "version": "9.12.1", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries"], "name": "launchdarkly-server-sdk", "requires_python": ">=3.8", "version": "9.8.1", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.77.7", "yanked": false}} -{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.78.0", "yanked": false}} +{"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9"], "name": "litellm", "requires_python": "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8", "version": "1.78.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": ">=3.8,<4.0", "version": "2.0.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.12.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "litestar", "requires_python": "<4.0,>=3.8", "version": "2.18.0", "yanked": false}} diff --git a/tox.ini b/tox.ini index f72f05e25a..668e5888b4 100644 --- a/tox.ini +++ b/tox.ini @@ -50,23 +50,23 @@ envlist = {py3.8,py3.11,py3.12}-anthropic-v0.16.0 {py3.8,py3.11,py3.12}-anthropic-v0.34.2 {py3.8,py3.11,py3.12}-anthropic-v0.52.2 - {py3.8,py3.12,py3.13}-anthropic-v0.69.0 + {py3.8,py3.12,py3.13}-anthropic-v0.70.0 {py3.9,py3.10,py3.11}-cohere-v5.4.0 - {py3.9,py3.11,py3.12}-cohere-v5.9.4 - {py3.9,py3.11,py3.12}-cohere-v5.13.12 - {py3.9,py3.11,py3.12}-cohere-v5.18.0 + {py3.9,py3.11,py3.12}-cohere-v5.10.0 + {py3.9,py3.11,py3.12}-cohere-v5.15.0 + {py3.9,py3.11,py3.12}-cohere-v5.19.0 {py3.9,py3.12,py3.13}-google_genai-v1.29.0 {py3.9,py3.12,py3.13}-google_genai-v1.34.0 {py3.9,py3.12,py3.13}-google_genai-v1.39.1 - {py3.9,py3.12,py3.13}-google_genai-v1.43.0 + {py3.9,py3.12,py3.13}-google_genai-v1.45.0 {py3.8,py3.10,py3.11}-huggingface_hub-v0.24.7 {py3.8,py3.12,py3.13}-huggingface_hub-v0.28.1 {py3.8,py3.12,py3.13}-huggingface_hub-v0.32.6 {py3.8,py3.12,py3.13}-huggingface_hub-v0.35.3 - {py3.9,py3.12,py3.13}-huggingface_hub-v1.0.0rc5 + {py3.9,py3.12,py3.13}-huggingface_hub-v1.0.0rc6 {py3.9,py3.11,py3.12}-langchain-base-v0.1.20 {py3.9,py3.11,py3.12}-langchain-base-v0.2.17 @@ -80,7 +80,7 @@ envlist = {py3.10,py3.12,py3.13}-langgraph-v1.0.0a4 {py3.9,py3.12,py3.13}-litellm-v1.77.7 - {py3.9,py3.12,py3.13}-litellm-v1.78.0 + {py3.9,py3.12,py3.13}-litellm-v1.78.2 {py3.8,py3.11,py3.12}-openai-base-v1.0.1 {py3.8,py3.12,py3.13}-openai-base-v1.109.1 @@ -100,7 +100,7 @@ envlist = {py3.6,py3.7}-boto3-v1.12.49 {py3.6,py3.9,py3.10}-boto3-v1.20.54 {py3.7,py3.11,py3.12}-boto3-v1.28.85 - {py3.9,py3.12,py3.13}-boto3-v1.40.50 + {py3.9,py3.12,py3.13}-boto3-v1.40.53 {py3.6,py3.7,py3.8}-chalice-v1.16.0 {py3.9,py3.12,py3.13}-chalice-v1.32.0 @@ -344,27 +344,27 @@ deps = anthropic-v0.16.0: anthropic==0.16.0 anthropic-v0.34.2: anthropic==0.34.2 anthropic-v0.52.2: anthropic==0.52.2 - anthropic-v0.69.0: anthropic==0.69.0 + anthropic-v0.70.0: anthropic==0.70.0 anthropic: pytest-asyncio anthropic-v0.16.0: httpx<0.28.0 anthropic-v0.34.2: httpx<0.28.0 cohere-v5.4.0: cohere==5.4.0 - cohere-v5.9.4: cohere==5.9.4 - cohere-v5.13.12: cohere==5.13.12 - cohere-v5.18.0: cohere==5.18.0 + cohere-v5.10.0: cohere==5.10.0 + cohere-v5.15.0: cohere==5.15.0 + cohere-v5.19.0: cohere==5.19.0 google_genai-v1.29.0: google-genai==1.29.0 google_genai-v1.34.0: google-genai==1.34.0 google_genai-v1.39.1: google-genai==1.39.1 - google_genai-v1.43.0: google-genai==1.43.0 + google_genai-v1.45.0: google-genai==1.45.0 google_genai: pytest-asyncio huggingface_hub-v0.24.7: huggingface_hub==0.24.7 huggingface_hub-v0.28.1: huggingface_hub==0.28.1 huggingface_hub-v0.32.6: huggingface_hub==0.32.6 huggingface_hub-v0.35.3: huggingface_hub==0.35.3 - huggingface_hub-v1.0.0rc5: huggingface_hub==1.0.0rc5 + huggingface_hub-v1.0.0rc6: huggingface_hub==1.0.0rc6 huggingface_hub: responses huggingface_hub: pytest-httpx @@ -387,7 +387,7 @@ deps = langgraph-v1.0.0a4: langgraph==1.0.0a4 litellm-v1.77.7: litellm==1.77.7 - litellm-v1.78.0: litellm==1.78.0 + litellm-v1.78.2: litellm==1.78.2 openai-base-v1.0.1: openai==1.0.1 openai-base-v1.109.1: openai==1.109.1 @@ -413,7 +413,7 @@ deps = boto3-v1.12.49: boto3==1.12.49 boto3-v1.20.54: boto3==1.20.54 boto3-v1.28.85: boto3==1.28.85 - boto3-v1.40.50: boto3==1.40.50 + boto3-v1.40.53: boto3==1.40.53 {py3.7,py3.8}-boto3: urllib3<2.0.0 chalice-v1.16.0: chalice==1.16.0 From ca4df942041810c397d44028e5fec8e6c570b101 Mon Sep 17 00:00:00 2001 From: Jason Cameron Date: Thu, 16 Oct 2025 09:21:20 -0400 Subject: [PATCH 06/15] fix(openai): Use non-deprecated Pydantic method to extract response text (#4942) Switch to Pydantic v2's `model_dump()` instead of `dict()` for serialization. The change avoids deprecation warnings during OpenAI response parsing that created issues in Sentry. --- sentry_sdk/integrations/openai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 19d7717b3c..315d54f750 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -237,7 +237,7 @@ def _set_output_data(span, response, kwargs, integration, finish_span=True): if hasattr(response, "choices"): if should_send_default_pii() and integration.include_prompts: - response_text = [choice.message.dict() for choice in response.choices] + response_text = [choice.message.model_dump() for choice in response.choices] if len(response_text) > 0: set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, response_text) From 814cd5a0bc8700478526796da53fd9217223d042 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 16 Oct 2025 15:26:43 +0200 Subject: [PATCH 07/15] fix(ai): introduce message truncation for openai (#4946) --- sentry_sdk/ai/utils.py | 53 +++- sentry_sdk/client.py | 19 ++ sentry_sdk/integrations/openai.py | 18 +- sentry_sdk/scope.py | 5 + tests/integrations/openai/test_openai.py | 63 ++++- tests/test_ai_monitoring.py | 300 +++++++++++++++++++++++ 6 files changed, 445 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 0c0b937006..1fb291bdac 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -1,14 +1,18 @@ import json - +from collections import deque from typing import TYPE_CHECKING +from sys import getsizeof if TYPE_CHECKING: - from typing import Any, Callable + from typing import Any, Callable, Dict, List, Optional, Tuple + from sentry_sdk.tracing import Span import sentry_sdk from sentry_sdk.utils import logger +MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB + class GEN_AI_ALLOWED_MESSAGE_ROLES: SYSTEM = "system" @@ -95,3 +99,48 @@ def get_start_span_function(): current_span is not None and current_span.containing_transaction is not None ) return sentry_sdk.start_span if transaction_exists else sentry_sdk.start_transaction + + +def _find_truncation_index(messages, max_bytes): + # type: (List[Dict[str, Any]], int) -> int + """ + Find the index of the first message that would exceed the max bytes limit. + Compute the individual message sizes, and return the index of the first message from the back + of the list that would exceed the max bytes limit. + """ + running_sum = 0 + for idx in range(len(messages) - 1, -1, -1): + size = len(json.dumps(messages[idx], separators=(",", ":")).encode("utf-8")) + running_sum += size + if running_sum > max_bytes: + return idx + 1 + + 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] + serialized_json = json.dumps(messages, separators=(",", ":")) + current_size = len(serialized_json.encode("utf-8")) + + if current_size <= max_bytes: + return messages, 0 + + truncation_index = _find_truncation_index(messages, max_bytes) + return messages[truncation_index:], truncation_index + + +def truncate_and_annotate_messages( + messages, span, scope, max_bytes=MAX_GEN_AI_MESSAGE_BYTES +): + # type: (Optional[List[Dict[str, Any]]], Any, Any, int) -> Optional[List[Dict[str, Any]]] + if not messages: + return None + + truncated_messages, removed_count = truncate_messages_by_size(messages, max_bytes) + if removed_count > 0: + scope._gen_ai_messages_truncated[span.span_id] = len(messages) - len( + truncated_messages + ) + + return truncated_messages diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index d17f922642..ffd899b545 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -598,6 +598,24 @@ def _prepare_event( if event_scrubber: event_scrubber.scrub_event(event) + if scope is not None and scope._gen_ai_messages_truncated: + spans = event.get("spans", []) # type: List[Dict[str, Any]] | AnnotatedValue + if isinstance(spans, list): + for span in spans: + span_id = span.get("span_id", None) + span_data = span.get("data", {}) + if ( + span_id + and span_id in scope._gen_ai_messages_truncated + and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data + ): + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES], + { + "len": scope._gen_ai_messages_truncated[span_id] + + len(span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES]) + }, + ) if previous_total_spans is not None: event["spans"] = AnnotatedValue( event.get("spans", []), {"len": previous_total_spans} @@ -606,6 +624,7 @@ def _prepare_event( event["breadcrumbs"] = AnnotatedValue( event.get("breadcrumbs", []), {"len": previous_total_breadcrumbs} ) + # Postprocess the event here so that annotated types do # generally not surface in before_send if event is not None: diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 315d54f750..bb93341f35 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -1,10 +1,13 @@ from functools import wraps -from collections.abc import Iterable import sentry_sdk from sentry_sdk import consts from sentry_sdk.ai.monitoring import record_token_usage -from sentry_sdk.ai.utils import set_data_normalized, normalize_message_roles +from sentry_sdk.ai.utils import ( + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, +) from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii @@ -18,7 +21,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, List, Optional, Callable, AsyncIterator, Iterator + from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator from sentry_sdk.tracing import Span try: @@ -189,9 +192,12 @@ def _set_input_data(span, kwargs, operation, integration): and integration.include_prompts ): normalized_messages = normalize_message_roles(messages) - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_MESSAGES, normalized_messages, unpack=False - ) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages(normalized_messages, span, scope) + if messages_data is not None: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) # Input attributes: Common set_data_normalized(span, SPANDATA.GEN_AI_SYSTEM, "openai") diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index f9caf7e1d6..5815a65440 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -188,6 +188,7 @@ class Scope: "_extras", "_breadcrumbs", "_n_breadcrumbs_truncated", + "_gen_ai_messages_truncated", "_event_processors", "_error_processors", "_should_capture", @@ -213,6 +214,7 @@ def __init__(self, ty=None, client=None): self._name = None # type: Optional[str] self._propagation_context = None # type: Optional[PropagationContext] self._n_breadcrumbs_truncated = 0 # type: int + self._gen_ai_messages_truncated = {} # type: Dict[str, int] self.client = NonRecordingClient() # type: sentry_sdk.client.BaseClient @@ -247,6 +249,7 @@ def __copy__(self): rv._breadcrumbs = copy(self._breadcrumbs) rv._n_breadcrumbs_truncated = self._n_breadcrumbs_truncated + rv._gen_ai_messages_truncated = self._gen_ai_messages_truncated.copy() rv._event_processors = self._event_processors.copy() rv._error_processors = self._error_processors.copy() rv._propagation_context = self._propagation_context @@ -1583,6 +1586,8 @@ def update_from_scope(self, scope): self._n_breadcrumbs_truncated = ( self._n_breadcrumbs_truncated + scope._n_breadcrumbs_truncated ) + if scope._gen_ai_messages_truncated: + self._gen_ai_messages_truncated.update(scope._gen_ai_messages_truncated) if scope._span: self._span = scope._span if scope._attachments: diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index 276a1b4886..ccef4f336e 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -1,3 +1,4 @@ +import json import pytest from sentry_sdk.utils import package_version @@ -6,7 +7,6 @@ from openai import NOT_GIVEN except ImportError: NOT_GIVEN = None - try: from openai import omit except ImportError: @@ -44,6 +44,9 @@ OpenAIIntegration, _calculate_token_usage, ) +from sentry_sdk.ai.utils import MAX_GEN_AI_MESSAGE_BYTES +from sentry_sdk._types import AnnotatedValue +from sentry_sdk.serializer import serialize from unittest import mock # python 3.3 and above @@ -1456,6 +1459,7 @@ def test_empty_tools_in_chat_completion(sentry_init, capture_events, tools): def test_openai_message_role_mapping(sentry_init, capture_events): """Test that OpenAI integration properly maps message roles like 'ai' to 'assistant'""" + sentry_init( integrations=[OpenAIIntegration(include_prompts=True)], traces_sample_rate=1.0, @@ -1465,7 +1469,6 @@ def test_openai_message_role_mapping(sentry_init, capture_events): client = OpenAI(api_key="z") client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION) - # Test messages with mixed roles including "ai" that should be mapped to "assistant" test_messages = [ {"role": "system", "content": "You are helpful."}, @@ -1476,11 +1479,9 @@ def test_openai_message_role_mapping(sentry_init, capture_events): with start_transaction(name="openai tx"): client.chat.completions.create(model="test-model", messages=test_messages) - + # Verify that the span was created correctly (event,) = events span = event["spans"][0] - - # Verify that the span was created correctly assert span["op"] == "gen_ai.chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"] @@ -1505,3 +1506,55 @@ def test_openai_message_role_mapping(sentry_init, capture_events): # Verify no "ai" roles remain roles = [msg["role"] for msg in stored_messages] assert "ai" not in roles + + +def test_openai_message_truncation(sentry_init, capture_events): + """Test that large messages are truncated properly in OpenAI integration.""" + sentry_init( + integrations=[OpenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + client = OpenAI(api_key="z") + client.chat.completions._post = mock.Mock(return_value=EXAMPLE_CHAT_COMPLETION) + + large_content = ( + "This is a very long message that will exceed our size limits. " * 1000 + ) + large_messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": large_content}, + {"role": "assistant", "content": large_content}, + {"role": "user", "content": large_content}, + ] + + with start_transaction(name="openai tx"): + client.chat.completions.create( + model="some-model", + messages=large_messages, + ) + + (event,) = events + span = event["spans"][0] + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"] + + messages_data = span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_data, str) + + parsed_messages = json.loads(messages_data) + assert isinstance(parsed_messages, list) + assert len(parsed_messages) <= len(large_messages) + + if "_meta" in event and len(parsed_messages) < len(large_messages): + meta_path = event["_meta"] + if ( + "spans" in meta_path + and "0" in meta_path["spans"] + and "data" in meta_path["spans"]["0"] + ): + span_meta = meta_path["spans"]["0"]["data"] + if SPANDATA.GEN_AI_REQUEST_MESSAGES in span_meta: + messages_meta = span_meta[SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert "len" in messages_meta.get("", {}) diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py index ee757f82cd..be66860384 100644 --- a/tests/test_ai_monitoring.py +++ b/tests/test_ai_monitoring.py @@ -1,7 +1,19 @@ +import json + import pytest import sentry_sdk +from sentry_sdk._types import AnnotatedValue from sentry_sdk.ai.monitoring import ai_track +from sentry_sdk.ai.utils import ( + MAX_GEN_AI_MESSAGE_BYTES, + set_data_normalized, + truncate_and_annotate_messages, + truncate_messages_by_size, + _find_truncation_index, +) +from sentry_sdk.serializer import serialize +from sentry_sdk.utils import safe_serialize def test_ai_track(sentry_init, capture_events): @@ -160,3 +172,291 @@ async def async_tool(**kwargs): assert span["description"] == "my async tool" assert span["op"] == "custom.async.operation" + + +@pytest.fixture +def sample_messages(): + """Sample messages similar to what gen_ai integrations would use""" + return [ + {"role": "system", "content": "You are a helpful assistant."}, + { + "role": "user", + "content": "What is the difference between a list and a tuple in Python?", + }, + { + "role": "assistant", + "content": "Lists are mutable and use [], tuples are immutable and use ().", + }, + {"role": "user", "content": "Can you give me some examples?"}, + { + "role": "assistant", + "content": "Sure! Here are examples:\n\n```python\n# List\nmy_list = [1, 2, 3]\nmy_list.append(4)\n\n# Tuple\nmy_tuple = (1, 2, 3)\n# my_tuple.append(4) would error\n```", + }, + ] + + +@pytest.fixture +def large_messages(): + """Messages that will definitely exceed size limits""" + large_content = "This is a very long message. " * 100 + return [ + {"role": "system", "content": large_content}, + {"role": "user", "content": large_content}, + {"role": "assistant", "content": large_content}, + {"role": "user", "content": large_content}, + ] + + +class TestTruncateMessagesBySize: + def test_no_truncation_needed(self, sample_messages): + """Test that messages under the limit are not truncated""" + result, removed_count = truncate_messages_by_size( + sample_messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES + ) + assert len(result) == len(sample_messages) + assert result == sample_messages + assert removed_count == 0 + + def test_truncation_removes_oldest_first(self, large_messages): + """Test that oldest messages are removed first during truncation""" + small_limit = 3000 + result, removed_count = truncate_messages_by_size( + large_messages, max_bytes=small_limit + ) + assert len(result) < len(large_messages) + + if result: + assert result[-1] == large_messages[-1] + assert removed_count == len(large_messages) - len(result) + + def test_empty_messages_list(self): + """Test handling of empty messages list""" + result, removed_count = truncate_messages_by_size( + [], max_bytes=MAX_GEN_AI_MESSAGE_BYTES // 500 + ) + assert result == [] + assert removed_count == 0 + + def test_find_truncation_index( + self, + ): + """Test that the truncation index is found correctly""" + # when represented in JSON, these are each 7 bytes long + messages = ["A" * 5, "B" * 5, "C" * 5, "D" * 5, "E" * 5] + truncation_index = _find_truncation_index(messages, 20) + assert truncation_index == 3 + assert messages[truncation_index:] == ["D" * 5, "E" * 5] + + messages = ["A" * 5, "B" * 5, "C" * 5, "D" * 5, "E" * 5] + truncation_index = _find_truncation_index(messages, 40) + assert truncation_index == 0 + assert messages[truncation_index:] == [ + "A" * 5, + "B" * 5, + "C" * 5, + "D" * 5, + "E" * 5, + ] + + def test_progressive_truncation(self, large_messages): + """Test that truncation works progressively with different limits""" + limits = [ + MAX_GEN_AI_MESSAGE_BYTES // 5, + MAX_GEN_AI_MESSAGE_BYTES // 10, + MAX_GEN_AI_MESSAGE_BYTES // 25, + MAX_GEN_AI_MESSAGE_BYTES // 100, + MAX_GEN_AI_MESSAGE_BYTES // 500, + ] + prev_count = len(large_messages) + + for limit in limits: + result = truncate_messages_by_size(large_messages, max_bytes=limit) + current_count = len(result) + + assert current_count <= prev_count + assert current_count >= 1 + prev_count = current_count + + +class TestTruncateAndAnnotateMessages: + def test_no_truncation_returns_list(self, sample_messages): + class MockSpan: + def __init__(self): + self.span_id = "test_span_id" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + span = MockSpan() + scope = MockScope() + result = truncate_and_annotate_messages(sample_messages, span, scope) + + assert isinstance(result, list) + assert not isinstance(result, AnnotatedValue) + assert len(result) == len(sample_messages) + assert result == sample_messages + assert span.span_id not in scope._gen_ai_messages_truncated + + def test_truncation_sets_metadata_on_scope(self, large_messages): + class MockSpan: + def __init__(self): + self.span_id = "test_span_id" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + small_limit = 1000 + span = MockSpan() + scope = MockScope() + original_count = len(large_messages) + result = truncate_and_annotate_messages( + large_messages, span, scope, max_bytes=small_limit + ) + + assert isinstance(result, list) + assert not isinstance(result, AnnotatedValue) + assert len(result) < len(large_messages) + n_removed = original_count - len(result) + assert scope._gen_ai_messages_truncated[span.span_id] == n_removed + + def test_scope_tracks_removed_messages(self, large_messages): + class MockSpan: + def __init__(self): + self.span_id = "test_span_id" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + small_limit = 1000 + original_count = len(large_messages) + span = MockSpan() + scope = MockScope() + + result = truncate_and_annotate_messages( + large_messages, span, scope, max_bytes=small_limit + ) + + n_removed = original_count - len(result) + assert scope._gen_ai_messages_truncated[span.span_id] == n_removed + assert len(result) + n_removed == original_count + + def test_empty_messages_returns_none(self): + class MockSpan: + def __init__(self): + self.span_id = "test_span_id" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + span = MockSpan() + scope = MockScope() + result = truncate_and_annotate_messages([], span, scope) + assert result is None + + result = truncate_and_annotate_messages(None, span, scope) + assert result is None + + def test_truncated_messages_newest_first(self, large_messages): + class MockSpan: + def __init__(self): + self.span_id = "test_span_id" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + small_limit = 3000 + span = MockSpan() + scope = MockScope() + result = truncate_and_annotate_messages( + large_messages, span, scope, max_bytes=small_limit + ) + + assert isinstance(result, list) + assert result[0] == large_messages[-len(result)] + + +class TestClientAnnotation: + def test_client_wraps_truncated_messages_in_annotated_value(self, large_messages): + """Test that client.py properly wraps truncated messages in AnnotatedValue using scope data""" + from sentry_sdk._types import AnnotatedValue + from sentry_sdk.consts import SPANDATA + + class MockSpan: + def __init__(self): + self.span_id = "test_span_123" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_messages_truncated = {} + + small_limit = 3000 + span = MockSpan() + scope = MockScope() + original_count = len(large_messages) + + # Simulate what integrations do + truncated_messages = truncate_and_annotate_messages( + large_messages, span, scope, max_bytes=small_limit + ) + span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, truncated_messages) + + # Verify metadata was set on scope + assert span.span_id in scope._gen_ai_messages_truncated + assert scope._gen_ai_messages_truncated[span.span_id] > 0 + + # Simulate what client.py does + event = {"spans": [{"span_id": span.span_id, "data": span.data.copy()}]} + + # Mimic client.py logic - using scope to get the removed count + for event_span in event["spans"]: + span_id = event_span.get("span_id") + span_data = event_span.get("data", {}) + if ( + span_id + and span_id in scope._gen_ai_messages_truncated + and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data + ): + messages = span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] + n_removed = scope._gen_ai_messages_truncated[span_id] + n_remaining = len(messages) if isinstance(messages, list) else 0 + original_count_calculated = n_removed + n_remaining + + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( + safe_serialize(messages), + {"len": original_count_calculated}, + ) + + # Verify the annotation happened + messages_value = event["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_value, AnnotatedValue) + assert messages_value.metadata["len"] == original_count + assert isinstance(messages_value.value, str) From b11c2f2c0e1b36b7c6128eabe994f8e7257a50d0 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 17 Oct 2025 11:16:35 +0200 Subject: [PATCH 08/15] fix(ai): correct size calculation, rename internal property for message truncation & add test (#4949) --- sentry_sdk/ai/utils.py | 4 +- sentry_sdk/client.py | 9 +-- sentry_sdk/scope.py | 12 ++-- tests/test_ai_monitoring.py | 113 +++++++++++++++++++++++++++--------- 4 files changed, 95 insertions(+), 43 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 1fb291bdac..06c9a23604 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -139,8 +139,6 @@ def truncate_and_annotate_messages( truncated_messages, removed_count = truncate_messages_by_size(messages, max_bytes) if removed_count > 0: - scope._gen_ai_messages_truncated[span.span_id] = len(messages) - len( - truncated_messages - ) + scope._gen_ai_original_message_count[span.span_id] = len(messages) return truncated_messages diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index ffd899b545..b4a3e8bb6b 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -598,7 +598,7 @@ def _prepare_event( if event_scrubber: event_scrubber.scrub_event(event) - if scope is not None and scope._gen_ai_messages_truncated: + if scope is not None and scope._gen_ai_original_message_count: spans = event.get("spans", []) # type: List[Dict[str, Any]] | AnnotatedValue if isinstance(spans, list): for span in spans: @@ -606,15 +606,12 @@ def _prepare_event( span_data = span.get("data", {}) if ( span_id - and span_id in scope._gen_ai_messages_truncated + and span_id in scope._gen_ai_original_message_count and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data ): span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES], - { - "len": scope._gen_ai_messages_truncated[span_id] - + len(span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES]) - }, + {"len": scope._gen_ai_original_message_count[span_id]}, ) if previous_total_spans is not None: event["spans"] = AnnotatedValue( diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 5815a65440..ecb8f370c5 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -188,7 +188,7 @@ class Scope: "_extras", "_breadcrumbs", "_n_breadcrumbs_truncated", - "_gen_ai_messages_truncated", + "_gen_ai_original_message_count", "_event_processors", "_error_processors", "_should_capture", @@ -214,7 +214,7 @@ def __init__(self, ty=None, client=None): self._name = None # type: Optional[str] self._propagation_context = None # type: Optional[PropagationContext] self._n_breadcrumbs_truncated = 0 # type: int - self._gen_ai_messages_truncated = {} # type: Dict[str, int] + self._gen_ai_original_message_count = {} # type: Dict[str, int] self.client = NonRecordingClient() # type: sentry_sdk.client.BaseClient @@ -249,7 +249,7 @@ def __copy__(self): rv._breadcrumbs = copy(self._breadcrumbs) rv._n_breadcrumbs_truncated = self._n_breadcrumbs_truncated - rv._gen_ai_messages_truncated = self._gen_ai_messages_truncated.copy() + rv._gen_ai_original_message_count = self._gen_ai_original_message_count.copy() rv._event_processors = self._event_processors.copy() rv._error_processors = self._error_processors.copy() rv._propagation_context = self._propagation_context @@ -1586,8 +1586,10 @@ def update_from_scope(self, scope): self._n_breadcrumbs_truncated = ( self._n_breadcrumbs_truncated + scope._n_breadcrumbs_truncated ) - if scope._gen_ai_messages_truncated: - self._gen_ai_messages_truncated.update(scope._gen_ai_messages_truncated) + if scope._gen_ai_original_message_count: + self._gen_ai_original_message_count.update( + scope._gen_ai_original_message_count + ) if scope._span: self._span = scope._span if scope._attachments: diff --git a/tests/test_ai_monitoring.py b/tests/test_ai_monitoring.py index be66860384..5ff136f810 100644 --- a/tests/test_ai_monitoring.py +++ b/tests/test_ai_monitoring.py @@ -1,4 +1,5 @@ import json +import uuid import pytest @@ -210,32 +211,32 @@ def large_messages(): class TestTruncateMessagesBySize: def test_no_truncation_needed(self, sample_messages): """Test that messages under the limit are not truncated""" - result, removed_count = truncate_messages_by_size( + result, truncation_index = truncate_messages_by_size( sample_messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES ) assert len(result) == len(sample_messages) assert result == sample_messages - assert removed_count == 0 + assert truncation_index == 0 def test_truncation_removes_oldest_first(self, large_messages): """Test that oldest messages are removed first during truncation""" small_limit = 3000 - result, removed_count = truncate_messages_by_size( + result, truncation_index = truncate_messages_by_size( large_messages, max_bytes=small_limit ) assert len(result) < len(large_messages) if result: assert result[-1] == large_messages[-1] - assert removed_count == len(large_messages) - len(result) + assert truncation_index == len(large_messages) - len(result) def test_empty_messages_list(self): """Test handling of empty messages list""" - result, removed_count = truncate_messages_by_size( + result, truncation_index = truncate_messages_by_size( [], max_bytes=MAX_GEN_AI_MESSAGE_BYTES // 500 ) assert result == [] - assert removed_count == 0 + assert truncation_index == 0 def test_find_truncation_index( self, @@ -290,7 +291,7 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} span = MockSpan() scope = MockScope() @@ -300,7 +301,7 @@ def __init__(self): assert not isinstance(result, AnnotatedValue) assert len(result) == len(sample_messages) assert result == sample_messages - assert span.span_id not in scope._gen_ai_messages_truncated + assert span.span_id not in scope._gen_ai_original_message_count def test_truncation_sets_metadata_on_scope(self, large_messages): class MockSpan: @@ -313,9 +314,9 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} - small_limit = 1000 + small_limit = 3000 span = MockSpan() scope = MockScope() original_count = len(large_messages) @@ -326,10 +327,9 @@ def __init__(self): assert isinstance(result, list) assert not isinstance(result, AnnotatedValue) assert len(result) < len(large_messages) - n_removed = original_count - len(result) - assert scope._gen_ai_messages_truncated[span.span_id] == n_removed + assert scope._gen_ai_original_message_count[span.span_id] == original_count - def test_scope_tracks_removed_messages(self, large_messages): + def test_scope_tracks_original_message_count(self, large_messages): class MockSpan: def __init__(self): self.span_id = "test_span_id" @@ -340,9 +340,9 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} - small_limit = 1000 + small_limit = 3000 original_count = len(large_messages) span = MockSpan() scope = MockScope() @@ -351,9 +351,8 @@ def __init__(self): large_messages, span, scope, max_bytes=small_limit ) - n_removed = original_count - len(result) - assert scope._gen_ai_messages_truncated[span.span_id] == n_removed - assert len(result) + n_removed == original_count + assert scope._gen_ai_original_message_count[span.span_id] == original_count + assert len(result) == 1 def test_empty_messages_returns_none(self): class MockSpan: @@ -366,7 +365,7 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} span = MockSpan() scope = MockScope() @@ -387,7 +386,7 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} small_limit = 3000 span = MockSpan() @@ -416,7 +415,7 @@ def set_data(self, key, value): class MockScope: def __init__(self): - self._gen_ai_messages_truncated = {} + self._gen_ai_original_message_count = {} small_limit = 3000 span = MockSpan() @@ -430,29 +429,27 @@ def __init__(self): span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, truncated_messages) # Verify metadata was set on scope - assert span.span_id in scope._gen_ai_messages_truncated - assert scope._gen_ai_messages_truncated[span.span_id] > 0 + assert span.span_id in scope._gen_ai_original_message_count + assert scope._gen_ai_original_message_count[span.span_id] > 0 # Simulate what client.py does event = {"spans": [{"span_id": span.span_id, "data": span.data.copy()}]} - # Mimic client.py logic - using scope to get the removed count + # Mimic client.py logic - using scope to get the original length for event_span in event["spans"]: span_id = event_span.get("span_id") span_data = event_span.get("data", {}) if ( span_id - and span_id in scope._gen_ai_messages_truncated + and span_id in scope._gen_ai_original_message_count and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data ): messages = span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] - n_removed = scope._gen_ai_messages_truncated[span_id] - n_remaining = len(messages) if isinstance(messages, list) else 0 - original_count_calculated = n_removed + n_remaining + n_original_count = scope._gen_ai_original_message_count[span_id] span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( safe_serialize(messages), - {"len": original_count_calculated}, + {"len": n_original_count}, ) # Verify the annotation happened @@ -460,3 +457,61 @@ def __init__(self): assert isinstance(messages_value, AnnotatedValue) assert messages_value.metadata["len"] == original_count assert isinstance(messages_value.value, str) + + def test_annotated_value_shows_correct_original_length(self, large_messages): + """Test that the annotated value correctly shows the original message count before truncation""" + from sentry_sdk.consts import SPANDATA + + class MockSpan: + def __init__(self): + self.span_id = "test_span_456" + self.data = {} + + def set_data(self, key, value): + self.data[key] = value + + class MockScope: + def __init__(self): + self._gen_ai_original_message_count = {} + + small_limit = 3000 + span = MockSpan() + scope = MockScope() + original_message_count = len(large_messages) + + truncated_messages = truncate_and_annotate_messages( + large_messages, span, scope, max_bytes=small_limit + ) + + assert len(truncated_messages) < original_message_count + + assert span.span_id in scope._gen_ai_original_message_count + stored_original_length = scope._gen_ai_original_message_count[span.span_id] + assert stored_original_length == original_message_count + + event = { + "spans": [ + { + "span_id": span.span_id, + "data": {SPANDATA.GEN_AI_REQUEST_MESSAGES: truncated_messages}, + } + ] + } + + for event_span in event["spans"]: + span_id = event_span.get("span_id") + span_data = event_span.get("data", {}) + if ( + span_id + and span_id in scope._gen_ai_original_message_count + and SPANDATA.GEN_AI_REQUEST_MESSAGES in span_data + ): + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES] = AnnotatedValue( + span_data[SPANDATA.GEN_AI_REQUEST_MESSAGES], + {"len": scope._gen_ai_original_message_count[span_id]}, + ) + + messages_value = event["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_value, AnnotatedValue) + assert messages_value.metadata["len"] == stored_original_length + assert len(messages_value.value) == len(truncated_messages) From 843c062903ae683bb2438f00c261bad4d215decd Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 17 Oct 2025 14:14:40 +0200 Subject: [PATCH 09/15] fix(ai): add message truncation in langchain (#4950) --- sentry_sdk/integrations/langchain.py | 62 ++++++++++----- .../integrations/langchain/test_langchain.py | 77 ++++++++++++++++++- 2 files changed, 116 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py index 724d908665..a8ff499831 100644 --- a/sentry_sdk/integrations/langchain.py +++ b/sentry_sdk/integrations/langchain.py @@ -9,6 +9,7 @@ normalize_message_roles, set_data_normalized, get_start_span_function, + truncate_and_annotate_messages, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration @@ -221,12 +222,17 @@ def on_llm_start( } for prompt in prompts ] - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs): # type: (SentryLangchainCallback, Dict[str, Any], List[List[BaseMessage]], UUID, Any) -> Any @@ -278,13 +284,17 @@ def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs): self._normalize_langchain_message(message) ) normalized_messages = normalize_message_roles(normalized_messages) - - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) def on_chat_model_end(self, response, *, run_id, **kwargs): # type: (SentryLangchainCallback, LLMResult, UUID, Any) -> Any @@ -758,12 +768,17 @@ def new_invoke(self, *args, **kwargs): and integration.include_prompts ): normalized_messages = normalize_message_roles([input]) - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) output = result.get("output") if ( @@ -813,12 +828,17 @@ def new_stream(self, *args, **kwargs): and integration.include_prompts ): normalized_messages = normalize_message_roles([input]) - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) # Run the agent result = f(self, *args, **kwargs) diff --git a/tests/integrations/langchain/test_langchain.py b/tests/integrations/langchain/test_langchain.py index 661208432f..1a6c4885fb 100644 --- a/tests/integrations/langchain/test_langchain.py +++ b/tests/integrations/langchain/test_langchain.py @@ -1,3 +1,4 @@ +import json from typing import List, Optional, Any, Iterator from unittest import mock from unittest.mock import Mock, patch @@ -884,8 +885,6 @@ def test_langchain_message_role_mapping(sentry_init, capture_events): # Parse the message data (might be JSON string) if isinstance(messages_data, str): - import json - try: messages = json.loads(messages_data) except json.JSONDecodeError: @@ -958,3 +957,77 @@ def test_langchain_message_role_normalization_units(): assert normalized[3]["role"] == "system" # system unchanged assert "role" not in normalized[4] # Message without role unchanged assert normalized[5] == "string message" # String message unchanged + + +def test_langchain_message_truncation(sentry_init, capture_events): + """Test that large messages are truncated properly in Langchain integration.""" + from langchain_core.outputs import LLMResult, Generation + + sentry_init( + integrations=[LangchainIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + callback = SentryLangchainCallback(max_span_map_size=100, include_prompts=True) + + run_id = "12345678-1234-1234-1234-123456789012" + serialized = {"_type": "openai-chat", "model_name": "gpt-3.5-turbo"} + + large_content = ( + "This is a very long message that will exceed our size limits. " * 1000 + ) + prompts = [ + "small message 1", + large_content, + large_content, + "small message 4", + "small message 5", + ] + + with start_transaction(): + callback.on_llm_start( + serialized=serialized, + prompts=prompts, + run_id=run_id, + invocation_params={ + "temperature": 0.7, + "max_tokens": 100, + "model": "gpt-3.5-turbo", + }, + ) + + response = LLMResult( + generations=[[Generation(text="The response")]], + llm_output={ + "token_usage": { + "total_tokens": 25, + "prompt_tokens": 10, + "completion_tokens": 15, + } + }, + ) + callback.on_llm_end(response=response, run_id=run_id) + + assert len(events) > 0 + tx = events[0] + assert tx["type"] == "transaction" + + llm_spans = [ + span for span in tx.get("spans", []) if span.get("op") == "gen_ai.pipeline" + ] + assert len(llm_spans) > 0 + + llm_span = llm_spans[0] + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in llm_span["data"] + + messages_data = llm_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_data, str) + + parsed_messages = json.loads(messages_data) + assert isinstance(parsed_messages, list) + assert len(parsed_messages) == 2 + assert "small message 4" in str(parsed_messages[0]) + assert "small message 5" in str(parsed_messages[1]) + assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5 From cc432a6f301c24612ee9d4d38003afe6e8589a65 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Fri, 17 Oct 2025 14:30:30 +0200 Subject: [PATCH 10/15] fix: Default breadcrumbs value for events without breadcrumbs (#4952) Change the default value when annotating truncated breadcrumbs to a dictionary with the expected keys instead of an empty list. Closes https://github.com/getsentry/sentry-python/issues/4951 --- sentry_sdk/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index b4a3e8bb6b..91096c6b4f 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -619,7 +619,8 @@ def _prepare_event( ) if previous_total_breadcrumbs is not None: event["breadcrumbs"] = AnnotatedValue( - event.get("breadcrumbs", []), {"len": previous_total_breadcrumbs} + event.get("breadcrumbs", {"values": []}), + {"len": previous_total_breadcrumbs}, ) # Postprocess the event here so that annotated types do From 23ec3984bdc7e048b2a8a82e8e2864065f841283 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 17 Oct 2025 15:24:20 +0200 Subject: [PATCH 11/15] fix(ai): add message truncation to langgraph (#4954) --- sentry_sdk/integrations/langgraph.py | 36 ++++++++---- .../integrations/langgraph/test_langgraph.py | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/integrations/langgraph.py b/sentry_sdk/integrations/langgraph.py index 11aa1facf4..5bb0e0fd08 100644 --- a/sentry_sdk/integrations/langgraph.py +++ b/sentry_sdk/integrations/langgraph.py @@ -2,7 +2,11 @@ from typing import Any, Callable, List, Optional import sentry_sdk -from sentry_sdk.ai.utils import set_data_normalized, normalize_message_roles +from sentry_sdk.ai.utils import ( + set_data_normalized, + normalize_message_roles, + truncate_and_annotate_messages, +) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii @@ -181,12 +185,17 @@ def new_invoke(self, *args, **kwargs): input_messages = _parse_langgraph_messages(args[0]) if input_messages: normalized_input_messages = normalize_message_roles(input_messages) - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_input_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) result = f(self, *args, **kwargs) @@ -232,12 +241,17 @@ async def new_ainvoke(self, *args, **kwargs): input_messages = _parse_langgraph_messages(args[0]) if input_messages: normalized_input_messages = normalize_message_roles(input_messages) - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - normalized_input_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_input_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages_data, + unpack=False, + ) result = await f(self, *args, **kwargs) diff --git a/tests/integrations/langgraph/test_langgraph.py b/tests/integrations/langgraph/test_langgraph.py index 6ec6d9a96d..7cb86a5b03 100644 --- a/tests/integrations/langgraph/test_langgraph.py +++ b/tests/integrations/langgraph/test_langgraph.py @@ -696,3 +696,60 @@ def __init__(self, content, message_type="human"): # Verify no "ai" roles remain roles = [msg["role"] for msg in stored_messages if "role" in msg] assert "ai" not in roles + + +def test_langgraph_message_truncation(sentry_init, capture_events): + """Test that large messages are truncated properly in Langgraph integration.""" + import json + + sentry_init( + integrations=[LanggraphIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + large_content = ( + "This is a very long message that will exceed our size limits. " * 1000 + ) + test_state = { + "messages": [ + MockMessage("small message 1", name="user"), + MockMessage(large_content, name="assistant"), + MockMessage(large_content, name="user"), + MockMessage("small message 4", name="assistant"), + MockMessage("small message 5", name="user"), + ] + } + + pregel = MockPregelInstance("test_graph") + + def original_invoke(self, *args, **kwargs): + return {"messages": args[0].get("messages", [])} + + with start_transaction(): + wrapped_invoke = _wrap_pregel_invoke(original_invoke) + result = wrapped_invoke(pregel, test_state) + + assert result is not None + assert len(events) > 0 + tx = events[0] + assert tx["type"] == "transaction" + + invoke_spans = [ + span for span in tx.get("spans", []) if span.get("op") == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) > 0 + + invoke_span = invoke_spans[0] + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"] + + messages_data = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_data, str) + + parsed_messages = json.loads(messages_data) + assert isinstance(parsed_messages, list) + assert len(parsed_messages) == 2 + assert "small message 4" in str(parsed_messages[0]) + assert "small message 5" in str(parsed_messages[1]) + assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5 From 2e259ae9e01a3240ab255415160f06c997c4422a Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 17 Oct 2025 15:24:29 +0200 Subject: [PATCH 12/15] fix(ai): add message trunction to anthropic (#4953) --- sentry_sdk/integrations/anthropic.py | 13 +++-- .../integrations/anthropic/test_anthropic.py | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 46c6b2a766..e61a3556e1 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -6,6 +6,7 @@ from sentry_sdk.ai.utils import ( set_data_normalized, normalize_message_roles, + truncate_and_annotate_messages, get_start_span_function, ) from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS @@ -145,12 +146,14 @@ def _set_input_data(span, kwargs, integration): normalized_messages.append(message) role_normalized_messages = normalize_message_roles(normalized_messages) - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - role_normalized_messages, - unpack=False, + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + role_normalized_messages, span, scope ) + if messages_data is not None: + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) set_data_normalized( span, SPANDATA.GEN_AI_RESPONSE_STREAMING, kwargs.get("stream", False) diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index e9065e2d32..f7c2d7e8a7 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -945,3 +945,52 @@ def mock_messages_create(*args, **kwargs): # Verify no "ai" roles remain roles = [msg["role"] for msg in stored_messages] assert "ai" not in roles + + +def test_anthropic_message_truncation(sentry_init, capture_events): + """Test that large messages are truncated properly in Anthropic integration.""" + sentry_init( + integrations=[AnthropicIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + client = Anthropic(api_key="z") + client.messages._post = mock.Mock(return_value=EXAMPLE_MESSAGE) + + large_content = ( + "This is a very long message that will exceed our size limits. " * 1000 + ) + messages = [ + {"role": "user", "content": "small message 1"}, + {"role": "assistant", "content": large_content}, + {"role": "user", "content": large_content}, + {"role": "assistant", "content": "small message 4"}, + {"role": "user", "content": "small message 5"}, + ] + + with start_transaction(): + client.messages.create(max_tokens=1024, messages=messages, model="model") + + assert len(events) > 0 + tx = events[0] + assert tx["type"] == "transaction" + + chat_spans = [ + span for span in tx.get("spans", []) if span.get("op") == OP.GEN_AI_CHAT + ] + assert len(chat_spans) > 0 + + chat_span = chat_spans[0] + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"] + + messages_data = chat_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + assert isinstance(messages_data, str) + + parsed_messages = json.loads(messages_data) + assert isinstance(parsed_messages, list) + assert len(parsed_messages) == 2 + assert "small message 4" in str(parsed_messages[0]) + assert "small message 5" in str(parsed_messages[1]) + assert tx["_meta"]["spans"]["0"]["data"]["gen_ai.request.messages"][""]["len"] == 5 From 1ad71634fa8d72b30c387f65966442c9438cd873 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Mon, 20 Oct 2025 08:50:39 +0200 Subject: [PATCH 13/15] fix(aws): Inject scopes in TimeoutThread exception with AWS lambda (#4914) Restore ServerlessTimeoutWarning isolation and current scope handling, so the scopes are active in an AWS lambda function and breadcrumbs and tags are applied on timeout. The behavior is restored to that before 2d392af3ea6da91ddbdde55d18e15c24dce6b59b. More information about how I found the bug is described in https://github.com/getsentry/sentry-python/issues/4912. Closes https://github.com/getsentry/sentry-python/issues/4894. --- sentry_sdk/integrations/aws_lambda.py | 2 ++ sentry_sdk/utils.py | 36 +++++++++++++++++-- .../TimeoutErrorScopeModified/.gitignore | 11 ++++++ .../TimeoutErrorScopeModified/index.py | 19 ++++++++++ .../aws_lambda/test_aws_lambda.py | 25 +++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore create mode 100644 tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py diff --git a/sentry_sdk/integrations/aws_lambda.py b/sentry_sdk/integrations/aws_lambda.py index 4990fd6e6a..85d1a6c28c 100644 --- a/sentry_sdk/integrations/aws_lambda.py +++ b/sentry_sdk/integrations/aws_lambda.py @@ -138,6 +138,8 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): timeout_thread = TimeoutThread( waiting_time, configured_time / MILLIS_TO_SECONDS, + isolation_scope=scope, + current_scope=sentry_sdk.get_current_scope(), ) # Starting the thread to raise timeout warning exception diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index cd825b29e2..3496178228 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1484,17 +1484,37 @@ class TimeoutThread(threading.Thread): waiting_time and raises a custom ServerlessTimeout exception. """ - def __init__(self, waiting_time, configured_timeout): - # type: (float, int) -> None + def __init__( + self, waiting_time, configured_timeout, isolation_scope=None, current_scope=None + ): + # type: (float, int, Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope]) -> None threading.Thread.__init__(self) self.waiting_time = waiting_time self.configured_timeout = configured_timeout + + self.isolation_scope = isolation_scope + self.current_scope = current_scope + self._stop_event = threading.Event() def stop(self): # type: () -> None self._stop_event.set() + def _capture_exception(self): + # type: () -> ExcInfo + exc_info = sys.exc_info() + + client = sentry_sdk.get_client() + event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "threading", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return exc_info + def run(self): # type: () -> None @@ -1510,6 +1530,18 @@ def run(self): integer_configured_timeout = integer_configured_timeout + 1 # Raising Exception after timeout duration is reached + if self.isolation_scope is not None and self.current_scope is not None: + with sentry_sdk.scope.use_isolation_scope(self.isolation_scope): + with sentry_sdk.scope.use_scope(self.current_scope): + try: + raise ServerlessTimeoutWarning( + "WARNING : Function is expected to get timed out. Configured timeout duration = {} seconds.".format( + integer_configured_timeout + ) + ) + except Exception: + reraise(*self._capture_exception()) + raise ServerlessTimeoutWarning( "WARNING : Function is expected to get timed out. Configured timeout duration = {} seconds.".format( integer_configured_timeout diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py new file mode 100644 index 0000000000..109245b90d --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TimeoutErrorScopeModified/index.py @@ -0,0 +1,19 @@ +import os +import time + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration(timeout_warning=True)], +) + + +def handler(event, context): + sentry_sdk.set_tag("custom_tag", "custom_value") + time.sleep(15) + return { + "event": event, + } diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 85da7e0b14..664220464c 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -223,6 +223,31 @@ def test_timeout_error(lambda_client, test_environment): assert exception["mechanism"]["type"] == "threading" +def test_timeout_error_scope_modified(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="TimeoutErrorScopeModified", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + (error_event,) = envelopes + + assert error_event["level"] == "error" + assert ( + error_event["extra"]["lambda"]["function_name"] == "TimeoutErrorScopeModified" + ) + + (exception,) = error_event["exception"]["values"] + assert not exception["mechanism"]["handled"] + assert exception["type"] == "ServerlessTimeoutWarning" + assert exception["value"].startswith( + "WARNING : Function is expected to get timed out. Configured timeout duration =" + ) + assert exception["mechanism"]["type"] == "threading" + + assert error_event["tags"]["custom_tag"] == "custom_value" + + @pytest.mark.parametrize( "aws_event, has_request_data, batch_size", [ From b12823e1fd287889d09367f52fdc2cb572a0f48d Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Mon, 20 Oct 2025 10:33:59 +0200 Subject: [PATCH 14/15] fix(gcp): Inject scopes in TimeoutThread exception with GCP (#4959) Restore ServerlessTimeoutWarning isolation and current scope handling, so the scopes are active in a GCP function and breadcrumbs and tags are applied on timeout. The behavior is restored to that before 2d392af. Closes https://github.com/getsentry/sentry-python/issues/4958. --- sentry_sdk/integrations/gcp.py | 7 ++++++- tests/integrations/gcp/test_gcp.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/gcp.py b/sentry_sdk/integrations/gcp.py index c637b7414a..2b0441f95d 100644 --- a/sentry_sdk/integrations/gcp.py +++ b/sentry_sdk/integrations/gcp.py @@ -75,7 +75,12 @@ def sentry_func(functionhandler, gcp_event, *args, **kwargs): ): waiting_time = configured_time - TIMEOUT_WARNING_BUFFER - timeout_thread = TimeoutThread(waiting_time, configured_time) + timeout_thread = TimeoutThread( + waiting_time, + configured_time, + isolation_scope=scope, + current_scope=sentry_sdk.get_current_scope(), + ) # Starting the thread to raise timeout warning exception timeout_thread.start() diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index d088f134fe..c27c7653aa 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -196,6 +196,7 @@ def test_timeout_error(run_cloud_function): functionhandler = None event = {} def cloud_function(functionhandler, event): + sentry_sdk.set_tag("cloud_function", "true") time.sleep(10) return "3" """ @@ -219,6 +220,8 @@ def cloud_function(functionhandler, event): assert exception["mechanism"]["type"] == "threading" assert not exception["mechanism"]["handled"] + assert envelope_items[0]["tags"]["cloud_function"] == "true" + def test_performance_no_error(run_cloud_function): envelope_items, _ = run_cloud_function( From ae337ca4b54b30621778c0847b93605e57e08314 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Oct 2025 11:57:40 +0000 Subject: [PATCH 15/15] release: 2.42.1 --- CHANGELOG.md | 19 +++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1119ddde..2200c2f429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2.42.1 + +### Various fixes & improvements + +- fix(gcp): Inject scopes in TimeoutThread exception with GCP (#4959) by @alexander-alderman-webb +- fix(aws): Inject scopes in TimeoutThread exception with AWS lambda (#4914) by @alexander-alderman-webb +- fix(ai): add message trunction to anthropic (#4953) by @shellmayr +- fix(ai): add message truncation to langgraph (#4954) by @shellmayr +- fix: Default breadcrumbs value for events without breadcrumbs (#4952) by @alexander-alderman-webb +- fix(ai): add message truncation in langchain (#4950) by @shellmayr +- fix(ai): correct size calculation, rename internal property for message truncation & add test (#4949) by @shellmayr +- fix(ai): introduce message truncation for openai (#4946) by @shellmayr +- fix(openai): Use non-deprecated Pydantic method to extract response text (#4942) by @JasonLovesDoggo +- ci: 🤖 Update test matrix with new releases (10/16) (#4945) by @github-actions +- Handle ValueError in scope resets (#4928) by @sl0thentr0py +- fix(litellm): Classify embeddings correctly (#4918) by @alexander-alderman-webb +- Generalize NOT_GIVEN check with omit for openai (#4926) by @sl0thentr0py +- ⚡️ Speed up function `_get_db_span_description` (#4924) by @misrasaurabh1 + ## 2.42.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 2d54f45170..e92d95931e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.42.0" +release = "2.42.1" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 2a3c9411be..c619faba83 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1348,4 +1348,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.42.0" +VERSION = "2.42.1" diff --git a/setup.py b/setup.py index 37c9cf54a6..e0894ae9e8 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.42.0", + version="2.42.1", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python",