From 07a3ea38429421e6e9db2083f2c22cd322664561 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:14:22 +0200 Subject: [PATCH 01/26] ref(subprocess): Create breadcrumbs directly in integration Move subprocess breadcrumb creation from the centralized `maybe_create_breadcrumbs_from_span` hook into the stdlib integration's `Popen.__init__` wrapper. This makes breadcrumbs work for both legacy spans and streamed spans, and removes the dependency on span internals. --- sentry_sdk/integrations/stdlib.py | 9 +++++++++ sentry_sdk/tracing_utils.py | 8 -------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 4de3819a77..3e19c06709 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -349,6 +349,15 @@ def sentry_patched_popen_init( else: span.set_tag("subprocess.pid", self.pid) + with capture_internal_exceptions(): + breadcrumb_data = {"subprocess.cwd": cwd} if cwd else {} + sentry_sdk.add_breadcrumb( + type="subprocess", + category="subprocess", + message=description, + data=breadcrumb_data, + ) + return rv subprocess.Popen.__init__ = sentry_patched_popen_init # type: ignore diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 989dee8bc6..6c903cd21d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -234,14 +234,6 @@ def maybe_create_breadcrumbs_from_span( else: scope.add_breadcrumb(type="http", category="httplib", data=span._data) - elif span.op == "subprocess": - scope.add_breadcrumb( - type="subprocess", - category="subprocess", - message=span.description, - data=span._data, - ) - def _get_frame_module_abs_path(frame: "FrameType") -> "Optional[str]": try: From 2f91234cfd7a39587a1394f3d972f3ca1f069f4e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:18:31 +0200 Subject: [PATCH 02/26] . --- sentry_sdk/integrations/stdlib.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 3e19c06709..c790372b73 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -350,12 +350,15 @@ def sentry_patched_popen_init( span.set_tag("subprocess.pid", self.pid) with capture_internal_exceptions(): - breadcrumb_data = {"subprocess.cwd": cwd} if cwd else {} + data = {} + if cwd: + data["subprocess.cwd"] = cwd + sentry_sdk.add_breadcrumb( type="subprocess", category="subprocess", message=description, - data=breadcrumb_data, + data=data, ) return rv From c3aea864fb2361e50788180bec01956250b2a770 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:35:51 +0200 Subject: [PATCH 03/26] ref: Move Redis breadcrumbs to integration --- .../integrations/redis/_async_common.py | 37 ++++++++++++++++++- sentry_sdk/integrations/redis/_sync_common.py | 37 ++++++++++++++++++- sentry_sdk/integrations/redis/utils.py | 26 ++++++++----- sentry_sdk/tracing_utils.py | 7 +--- 4 files changed, 88 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py index bd83d22191..2c4c406cad 100644 --- a/sentry_sdk/integrations/redis/_async_common.py +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -2,13 +2,16 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN +from sentry_sdk.integrations.redis.consts import ( + SPAN_ORIGIN, +) from sentry_sdk.integrations.redis.modules.caches import ( _compile_cache_span_properties, _set_cache_data, ) from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties from sentry_sdk.integrations.redis.utils import ( + _extract_key, _get_safe_command, _set_client_data, _set_pipeline_data, @@ -81,7 +84,20 @@ async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": command_seq, ) - return await old_execute(self, *args, **kwargs) + rv = await old_execute(self, *args, **kwargs) + + with capture_internal_exceptions(): + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.is_transaction, + }, + ) + + return rv pipeline_cls.execute = _sentry_execute # type: ignore @@ -177,6 +193,23 @@ async def _sentry_execute_command( _set_cache_data(cache_span, self, cache_properties, value) cache_span.__exit__(None, None, None) + with capture_internal_exceptions(): + data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=data, + ) + return value cls.execute_command = _sentry_execute_command # type: ignore diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py index 3afa7f282c..43d053fc0e 100644 --- a/sentry_sdk/integrations/redis/_sync_common.py +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -2,13 +2,16 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN +from sentry_sdk.integrations.redis.consts import ( + SPAN_ORIGIN, +) from sentry_sdk.integrations.redis.modules.caches import ( _compile_cache_span_properties, _set_cache_data, ) from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties from sentry_sdk.integrations.redis.utils import ( + _extract_key, _get_safe_command, _set_client_data, _set_pipeline_data, @@ -76,7 +79,20 @@ def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": command_seq, ) - return old_execute(self, *args, **kwargs) + rv = old_execute(self, *args, **kwargs) + + with capture_internal_exceptions(): + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.transaction, + }, + ) + + return rv pipeline_cls.execute = sentry_patched_execute @@ -176,6 +192,23 @@ def sentry_patched_execute_command( _set_cache_data(cache_span, self, cache_properties, value) cache_span.__exit__(None, None, None) + with capture_internal_exceptions(): + data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=data, + ) + return value cls.execute_command = sentry_patched_execute_command diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py index c12752a530..29b61bb95d 100644 --- a/sentry_sdk/integrations/redis/utils.py +++ b/sentry_sdk/integrations/redis/utils.py @@ -153,12 +153,20 @@ def _set_client_data( span.set_tag("redis.command", name) span.set_tag(SPANDATA.DB_OPERATION, name) - if name and args: - name_low = name.lower() - if (name_low in _SINGLE_KEY_COMMANDS) or ( - name_low in _MULTI_KEY_COMMANDS and len(args) == 1 - ): - if isinstance(span, StreamedSpan): - span.set_attribute("db.redis.key", args[0]) - else: - span.set_tag("redis.key", args[0]) + key = _extract_key(name, args) + if key is not None: + if isinstance(span, StreamedSpan): + span.set_attribute("db.redis.key", key) + else: + span.set_tag("redis.key", key) + + +def _extract_key(name: str, args: "Any") -> Optional[str]: + if not name or not args: + return None + + name_low = name.lower() + if (name_low in _SINGLE_KEY_COMMANDS) or ( + name_low in _MULTI_KEY_COMMANDS and len(args) == 1 + ): + return args[0] diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 6c903cd21d..b3658f6f91 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -213,12 +213,7 @@ def record_sql_queries( def maybe_create_breadcrumbs_from_span( scope: "sentry_sdk.Scope", span: "sentry_sdk.tracing.Span" ) -> None: - if span.op == OP.DB_REDIS: - scope.add_breadcrumb( - message=span.description, type="redis", category="redis", data=span._tags - ) - - elif span.op == OP.HTTP_CLIENT: + if span.op == OP.HTTP_CLIENT: level = None status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE) if status_code: From 15d2e2f0e088ae5d2ee2132c35630c19a4ff8a9c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:38:24 +0200 Subject: [PATCH 04/26] . --- sentry_sdk/integrations/redis/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py index 29b61bb95d..8d21640c20 100644 --- a/sentry_sdk/integrations/redis/utils.py +++ b/sentry_sdk/integrations/redis/utils.py @@ -161,7 +161,7 @@ def _set_client_data( span.set_tag("redis.key", key) -def _extract_key(name: str, args: "Any") -> Optional[str]: +def _extract_key(name: str, args: "Any") -> "Optional[str]": if not name or not args: return None From 53ab7f0e020fb288b9f7a2d2370e28becce010c4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:43:08 +0200 Subject: [PATCH 05/26] really mypy? --- sentry_sdk/integrations/redis/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py index 8d21640c20..c9cf38cdbd 100644 --- a/sentry_sdk/integrations/redis/utils.py +++ b/sentry_sdk/integrations/redis/utils.py @@ -170,3 +170,5 @@ def _extract_key(name: str, args: "Any") -> "Optional[str]": name_low in _MULTI_KEY_COMMANDS and len(args) == 1 ): return args[0] + + return None From 0ea13df3a0033f30a8cf71ef80bad292c396e6e0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 09:46:39 +0200 Subject: [PATCH 06/26] . --- sentry_sdk/integrations/stdlib.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index c790372b73..cacf02e36f 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -342,13 +342,6 @@ def sentry_patched_popen_init( if cwd and isinstance(span, Span): span.set_data("subprocess.cwd", cwd) - rv = old_popen_init(self, *a, **kw) - - if isinstance(span, StreamedSpan): - span.set_attribute(SPANDATA.PROCESS_PID, self.pid) - else: - span.set_tag("subprocess.pid", self.pid) - with capture_internal_exceptions(): data = {} if cwd: @@ -361,6 +354,13 @@ def sentry_patched_popen_init( data=data, ) + rv = old_popen_init(self, *a, **kw) + + if isinstance(span, StreamedSpan): + span.set_attribute(SPANDATA.PROCESS_PID, self.pid) + else: + span.set_tag("subprocess.pid", self.pid) + return rv subprocess.Popen.__init__ = sentry_patched_popen_init # type: ignore From 2f54487a992d04c5345747d8db52abf87f8c610a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:04:48 +0200 Subject: [PATCH 07/26] move even earlier --- sentry_sdk/integrations/stdlib.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index cacf02e36f..d37ac9fb0f 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -305,6 +305,18 @@ def sentry_patched_popen_init( env = None + with capture_internal_exceptions(): + data = {} + if cwd: + data["subprocess.cwd"] = cwd + + sentry_sdk.add_breadcrumb( + type="subprocess", + category="subprocess", + message=description, + data=data, + ) + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) span: "Union[Span, StreamedSpan]" if span_streaming: @@ -342,18 +354,6 @@ def sentry_patched_popen_init( if cwd and isinstance(span, Span): span.set_data("subprocess.cwd", cwd) - with capture_internal_exceptions(): - data = {} - if cwd: - data["subprocess.cwd"] = cwd - - sentry_sdk.add_breadcrumb( - type="subprocess", - category="subprocess", - message=description, - data=data, - ) - rv = old_popen_init(self, *a, **kw) if isinstance(span, StreamedSpan): From 8e6087f2ce40108689ccfac51af1a0fcf1b5ed00 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:11:37 +0200 Subject: [PATCH 08/26] . --- .../integrations/redis/_async_common.py | 60 +++++++++--------- sentry_sdk/integrations/redis/_sync_common.py | 61 +++++++++---------- 2 files changed, 60 insertions(+), 61 deletions(-) diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py index 2c4c406cad..5221a5c195 100644 --- a/sentry_sdk/integrations/redis/_async_common.py +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -45,6 +45,17 @@ async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if client.get_integration(RedisIntegration) is None: return await old_execute(self, *args, **kwargs) + with capture_internal_exceptions(): + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.is_transaction, + }, + ) + span_streaming = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" @@ -86,17 +97,6 @@ async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": rv = await old_execute(self, *args, **kwargs) - with capture_internal_exceptions(): - sentry_sdk.add_breadcrumb( - message="redis.pipeline.execute", - type="redis", - category="redis", - data={ - "redis.is_cluster": is_cluster, - "redis.transaction": False if is_cluster else self.is_transaction, - }, - ) - return rv pipeline_cls.execute = _sentry_execute # type: ignore @@ -119,6 +119,25 @@ async def _sentry_execute_command( if integration is None: return await old_execute_command(self, name, *args, **kwargs) + db_properties = _compile_db_span_properties(integration, name, args) + + with capture_internal_exceptions(): + data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=data, + ) + span_streaming = has_span_streaming_enabled(client.options) if span_streaming and sentry_sdk.traces.get_current_span() is None: @@ -156,8 +175,6 @@ async def _sentry_execute_command( ) cache_span.__enter__() - db_properties = _compile_db_span_properties(integration, name, args) - additional_db_span_attributes = {} with capture_internal_exceptions(): additional_db_span_attributes[SPANDATA.DB_QUERY_TEXT] = _get_safe_command( @@ -193,23 +210,6 @@ async def _sentry_execute_command( _set_cache_data(cache_span, self, cache_properties, value) cache_span.__exit__(None, None, None) - with capture_internal_exceptions(): - data = { - "redis.is_cluster": is_cluster, - "redis.command": name, - "db.operation": name, - } - key = _extract_key(name, args) - if key is not None: - data["redis.key"] = key - - sentry_sdk.add_breadcrumb( - message=db_properties["description"], - type="redis", - category="redis", - data=data, - ) - return value cls.execute_command = _sentry_execute_command # type: ignore diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py index 43d053fc0e..eea99b2c58 100644 --- a/sentry_sdk/integrations/redis/_sync_common.py +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -42,8 +42,18 @@ def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if client.get_integration(RedisIntegration) is None: return old_execute(self, *args, **kwargs) - span_streaming = has_span_streaming_enabled(client.options) + with capture_internal_exceptions(): + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.transaction, + }, + ) + span_streaming = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" if span_streaming: if sentry_sdk.traces.get_current_span() is None: @@ -81,17 +91,6 @@ def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": rv = old_execute(self, *args, **kwargs) - with capture_internal_exceptions(): - sentry_sdk.add_breadcrumb( - message="redis.pipeline.execute", - type="redis", - category="redis", - data={ - "redis.is_cluster": is_cluster, - "redis.transaction": False if is_cluster else self.transaction, - }, - ) - return rv pipeline_cls.execute = sentry_patched_execute @@ -118,6 +117,25 @@ def sentry_patched_execute_command( if integration is None: return old_execute_command(self, name, *args, **kwargs) + db_properties = _compile_db_span_properties(integration, name, args) + + with capture_internal_exceptions(): + data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=data, + ) + span_streaming = has_span_streaming_enabled(client.options) if span_streaming and sentry_sdk.traces.get_current_span() is None: @@ -155,8 +173,6 @@ def sentry_patched_execute_command( ) cache_span.__enter__() - db_properties = _compile_db_span_properties(integration, name, args) - additional_db_span_attributes = {} with capture_internal_exceptions(): additional_db_span_attributes[SPANDATA.DB_QUERY_TEXT] = _get_safe_command( @@ -192,23 +208,6 @@ def sentry_patched_execute_command( _set_cache_data(cache_span, self, cache_properties, value) cache_span.__exit__(None, None, None) - with capture_internal_exceptions(): - data = { - "redis.is_cluster": is_cluster, - "redis.command": name, - "db.operation": name, - } - key = _extract_key(name, args) - if key is not None: - data["redis.key"] = key - - sentry_sdk.add_breadcrumb( - message=db_properties["description"], - type="redis", - category="redis", - data=data, - ) - return value cls.execute_command = sentry_patched_execute_command From 91fe6a4c49bfcd31776d3871847d1a51c6963c78 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:40:59 +0200 Subject: [PATCH 09/26] . --- sentry_sdk/integrations/redis/_async_common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py index 5221a5c195..3622d19cb2 100644 --- a/sentry_sdk/integrations/redis/_async_common.py +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -95,9 +95,7 @@ async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": command_seq, ) - rv = await old_execute(self, *args, **kwargs) - - return rv + return await old_execute(self, *args, **kwargs) pipeline_cls.execute = _sentry_execute # type: ignore From 5858c87e3813f7e40d2e4220112554da1ac0d73a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:42:05 +0200 Subject: [PATCH 10/26] . --- sentry_sdk/integrations/redis/_sync_common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py index eea99b2c58..4c5ddeebfb 100644 --- a/sentry_sdk/integrations/redis/_sync_common.py +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -89,9 +89,7 @@ def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": command_seq, ) - rv = old_execute(self, *args, **kwargs) - - return rv + return old_execute(self, *args, **kwargs) pipeline_cls.execute = sentry_patched_execute From 4e093aa3a81f7664aa02406917ff611fd6a38210 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:48:55 +0200 Subject: [PATCH 11/26] . --- sentry_sdk/integrations/stdlib.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index d37ac9fb0f..c764605c45 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -305,17 +305,12 @@ def sentry_patched_popen_init( env = None - with capture_internal_exceptions(): - data = {} - if cwd: - data["subprocess.cwd"] = cwd - - sentry_sdk.add_breadcrumb( - type="subprocess", - category="subprocess", - message=description, - data=data, - ) + sentry_sdk.add_breadcrumb( + type="subprocess", + category="subprocess", + message=description, + data={"subprocess.cwd": cwd} if cwd else {}, + ) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) span: "Union[Span, StreamedSpan]" From 7472af9fa7656fa14e6f74f35263e1dc051255cc Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:51:14 +0200 Subject: [PATCH 12/26] remove extra guards --- .../integrations/redis/_async_common.py | 50 +++++++++---------- sentry_sdk/integrations/redis/_sync_common.py | 50 +++++++++---------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py index 3622d19cb2..956fe91154 100644 --- a/sentry_sdk/integrations/redis/_async_common.py +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -45,16 +45,15 @@ async def _sentry_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if client.get_integration(RedisIntegration) is None: return await old_execute(self, *args, **kwargs) - with capture_internal_exceptions(): - sentry_sdk.add_breadcrumb( - message="redis.pipeline.execute", - type="redis", - category="redis", - data={ - "redis.is_cluster": is_cluster, - "redis.transaction": False if is_cluster else self.is_transaction, - }, - ) + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.is_transaction, + }, + ) span_streaming = has_span_streaming_enabled(client.options) @@ -119,22 +118,21 @@ async def _sentry_execute_command( db_properties = _compile_db_span_properties(integration, name, args) - with capture_internal_exceptions(): - data = { - "redis.is_cluster": is_cluster, - "redis.command": name, - "db.operation": name, - } - key = _extract_key(name, args) - if key is not None: - data["redis.key"] = key - - sentry_sdk.add_breadcrumb( - message=db_properties["description"], - type="redis", - category="redis", - data=data, - ) + breadcrumb_data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + breadcrumb_data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=breadcrumb_data, + ) span_streaming = has_span_streaming_enabled(client.options) diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py index 4c5ddeebfb..fcb1822094 100644 --- a/sentry_sdk/integrations/redis/_sync_common.py +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -42,16 +42,15 @@ def sentry_patched_execute(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": if client.get_integration(RedisIntegration) is None: return old_execute(self, *args, **kwargs) - with capture_internal_exceptions(): - sentry_sdk.add_breadcrumb( - message="redis.pipeline.execute", - type="redis", - category="redis", - data={ - "redis.is_cluster": is_cluster, - "redis.transaction": False if is_cluster else self.transaction, - }, - ) + sentry_sdk.add_breadcrumb( + message="redis.pipeline.execute", + type="redis", + category="redis", + data={ + "redis.is_cluster": is_cluster, + "redis.transaction": False if is_cluster else self.transaction, + }, + ) span_streaming = has_span_streaming_enabled(client.options) span: "Union[Span, StreamedSpan]" @@ -117,22 +116,21 @@ def sentry_patched_execute_command( db_properties = _compile_db_span_properties(integration, name, args) - with capture_internal_exceptions(): - data = { - "redis.is_cluster": is_cluster, - "redis.command": name, - "db.operation": name, - } - key = _extract_key(name, args) - if key is not None: - data["redis.key"] = key - - sentry_sdk.add_breadcrumb( - message=db_properties["description"], - type="redis", - category="redis", - data=data, - ) + breadcrumb_data = { + "redis.is_cluster": is_cluster, + "redis.command": name, + "db.operation": name, + } + key = _extract_key(name, args) + if key is not None: + breadcrumb_data["redis.key"] = key + + sentry_sdk.add_breadcrumb( + message=db_properties["description"], + type="redis", + category="redis", + data=breadcrumb_data, + ) span_streaming = has_span_streaming_enabled(client.options) From f492ba441a00944df69be721c8e4a08b3ca6a46c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 13:07:02 +0200 Subject: [PATCH 13/26] ref(aiohttp): Move breadcrumb capture to integration --- sentry_sdk/integrations/aiohttp.py | 136 +++++++++------ sentry_sdk/tracing_utils.py | 16 ++ tests/integrations/aiohttp/test_aiohttp.py | 188 ++++++++++++++++++++- 3 files changed, 286 insertions(+), 54 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 858bf273f2..0743849a70 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -36,6 +36,7 @@ TransactionSource, ) from sentry_sdk.tracing_utils import ( + add_http_breadcrumb, add_http_request_source, has_span_streaming_enabled, should_propagate_trace, @@ -388,6 +389,8 @@ async def on_request_start( with capture_internal_exceptions(): parsed_url = parse_url(str(params.url), sanitize=False) + breadcrumb = {} + span_name = "%s %s" % ( method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, @@ -395,53 +398,60 @@ async def on_request_start( span: "Union[Span, StreamedSpan, None]" if has_span_streaming_enabled(client.options): - if sentry_sdk.traces.get_current_span() is None: - span = None - else: - attributes: "Attributes" = { - "sentry.op": OP.HTTP_CLIENT, - "sentry.origin": AioHttpIntegration.origin, - "http.request.method": method, - } - if parsed_url is not None: - if has_data_collection_enabled(client.options): - url_full = parsed_url.url - attributes["url.path"] = params.url.path - - if parsed_url.query: - filtered_query = ( - _apply_data_collection_filtering_to_query_string( - query_string=parsed_url.query, - behaviour=client.options["data_collection"][ - "url_query_params" - ], - ) + attributes: "Attributes" = { + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": AioHttpIntegration.origin, + "http.request.method": method, + } + if parsed_url is not None: + if has_data_collection_enabled(client.options): + url_full = parsed_url.url + attributes["url.path"] = params.url.path + + if parsed_url.query: + filtered_query = ( + _apply_data_collection_filtering_to_query_string( + query_string=parsed_url.query, + behaviour=client.options["data_collection"][ + "url_query_params" + ], ) - if filtered_query: - attributes["url.query"] = filtered_query - url_full += "?" + filtered_query - - if parsed_url.fragment: - attributes["url.fragment"] = parsed_url.fragment - url_full += "#" + parsed_url.fragment - - attributes["url.full"] = url_full - elif should_send_default_pii(): - url_full = parsed_url.url - attributes["url.path"] = params.url.path - - if parsed_url.query: - url_full += "?" + parsed_url.query - attributes["url.query"] = parsed_url.query - if parsed_url.fragment: - url_full += "#" + parsed_url.fragment - attributes["url.fragment"] = parsed_url.fragment - - attributes["url.full"] = url_full - - span = sentry_sdk.traces.start_span( - name=span_name, attributes=attributes - ) + ) + if filtered_query: + attributes["url.query"] = filtered_query + url_full += "?" + filtered_query + breadcrumb[SPANDATA.HTTP_QUERY] = filtered_query + + if parsed_url.fragment: + attributes["url.fragment"] = parsed_url.fragment + url_full += "#" + parsed_url.fragment + breadcrumb[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + + attributes["url.full"] = url_full + breadcrumb["url"] = url_full + + elif should_send_default_pii(): + url_full = parsed_url.url + attributes["url.path"] = params.url.path + + if parsed_url.query: + url_full += "?" + parsed_url.query + attributes["url.query"] = parsed_url.query + breadcrumb[SPANDATA.HTTP_QUERY] = parsed_url.query + if parsed_url.fragment: + url_full += "#" + parsed_url.fragment + attributes["url.fragment"] = parsed_url.fragment + breadcrumb[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + + attributes["url.full"] = url_full + breadcrumb["url"] = url_full + + if sentry_sdk.traces.get_current_span() is None: + span = None + else: + span = sentry_sdk.traces.start_span( + name=span_name, attributes=attributes + ) else: legacy_span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, @@ -451,8 +461,13 @@ async def on_request_start( legacy_span.set_data(SPANDATA.HTTP_METHOD, method) if parsed_url is not None: legacy_span.set_data("url", parsed_url.url) - legacy_span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - legacy_span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + breadcrumb.update( + { + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + "url": parsed_url.url, + } + ) span = legacy_span if should_propagate_trace(client, str(params.url)): @@ -475,18 +490,35 @@ async def on_request_start( else: params.headers[key] = value - trace_config_ctx.span = span + trace_config_ctx._sentry_span = span + trace_config_ctx._sentry_breadcrumb = breadcrumb async def on_request_end( session: "ClientSession", trace_config_ctx: "SimpleNamespace", params: "TraceRequestEndParams", ) -> None: - if trace_config_ctx.span is None: + status = int(params.response.status) + + breadcrumb = trace_config_ctx._sentry_breadcrumb + if breadcrumb is not None: + breadcrumb.update( + { + SPANDATA.HTTP_METHOD: params.method.upper(), + SPANDATA.HTTP_STATUS_CODE: status, + "reason": params.response.reason, + } + ) + + add_http_breadcrumb( + status, + breadcrumb, + ) + + if trace_config_ctx._sentry_span is None: return - span = trace_config_ctx.span - status = int(params.response.status) + span = trace_config_ctx._sentry_span if isinstance(span, StreamedSpan): span.set_attribute("http.response.status_code", status) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index b3658f6f91..c740397653 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -210,6 +210,22 @@ def record_sql_queries( yield span +def add_http_breadcrumb(status_code, data): + # type: (Optional[int], dict[str, Any]) -> None + level = None + if status_code: + if 500 <= status_code <= 599: + level = "error" + elif 400 <= status_code <= 499: + level = "warning" + + kwargs: "dict[str, Any]" = {"type": "http", "category": "httplib", "data": data} + if level: + kwargs["level"] = level + + sentry_sdk.add_breadcrumb(**kwargs) + + def maybe_create_breadcrumbs_from_span( scope: "sentry_sdk.Scope", span: "sentry_sdk.tracing.Span" ) -> None: diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index f70964e6dd..4ce2b69647 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -484,14 +484,18 @@ async def hello(request): @pytest.mark.asyncio async def test_crumb_capture( - sentry_init, aiohttp_raw_server, aiohttp_client, capture_events + sentry_init, + aiohttp_raw_server, + aiohttp_client, + capture_events, ): def before_breadcrumb(crumb, hint): crumb["data"]["extra"] = "foo" return crumb sentry_init( - integrations=[AioHttpIntegration()], before_breadcrumb=before_breadcrumb + integrations=[AioHttpIntegration()], + before_breadcrumb=before_breadcrumb, ) async def handler(request): @@ -525,6 +529,90 @@ async def handler(request): ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "pii_options,url_expected,query_expected", + [ + ({}, False, False), + ({"send_default_pii": True}, True, True), + ({"send_default_pii": False}, False, False), + ( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": []} + } + } + }, + True, + True, + ), + ( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": []} + } + } + }, + True, + False, + ), + ], +) +async def test_crumb_capture_span_streaming( + sentry_init, + aiohttp_raw_server, + aiohttp_client, + capture_events, + pii_options, + url_expected, + query_expected, +): + def before_breadcrumb(crumb, hint): + crumb["data"]["extra"] = "foo" + return crumb + + sentry_init( + integrations=[AioHttpIntegration()], + before_breadcrumb=before_breadcrumb, + trace_lifecycle="stream", + **pii_options, + ) + + async def handler(request): + return web.Response(text="OK") + + raw_server = await aiohttp_raw_server(handler) + + events = capture_events() + + client = await aiohttp_client(raw_server) + resp = await client.get("/?query=value") + assert resp.status == 200 + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + expected = { + "http.method": "GET", + "http.response.status_code": 200, + "reason": "OK", + } + + if url_expected: + if query_expected: + expected["url"] = f"http://127.0.0.1:{raw_server.port}/?query=value" + else: + expected["url"] = ( + f"http://127.0.0.1:{raw_server.port}/?query=%5BFiltered%5D" + ) + + @pytest.mark.parametrize( "status_code,level", [ @@ -579,6 +667,102 @@ async def handler(request): ) +@pytest.mark.parametrize( + "status_code,level,reason", + [ + (200, None, "OK"), + (301, None, "Moved Permanently"), + (403, "warning", "Forbidden"), + (405, "warning", "Method Not Allowed"), + (500, "error", "Internal Server Error"), + ], +) +@pytest.mark.parametrize( + "pii_options,url_expected,query_expected", + [ + ({}, False, False), + ({"send_default_pii": True}, True, True), + ({"send_default_pii": False}, False, False), + ( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": []} + } + } + }, + True, + True, + ), + ( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": []} + } + } + }, + True, + False, + ), + ], +) +@pytest.mark.asyncio +async def test_crumb_capture_client_error_span_streaming( + sentry_init, + aiohttp_raw_server, + aiohttp_client, + capture_events, + status_code, + level, + reason, + pii_options, + url_expected, + query_expected, +): + sentry_init( + integrations=[AioHttpIntegration()], trace_lifecycle="stream", **pii_options + ) + + async def handler(request): + return web.Response(status=status_code) + + raw_server = await aiohttp_raw_server(handler) + + events = capture_events() + + client = await aiohttp_client(raw_server) + resp = await client.get("/?query=value") + assert resp.status == status_code + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + if level is None: + assert "level" not in crumb + else: + assert crumb["level"] == level + assert crumb["category"] == "httplib" + + expected = { + "http.method": "GET", + "http.response.status_code": status_code, + "reason": reason, + } + + if url_expected: + if query_expected: + expected["url"] = f"http://127.0.0.1:{raw_server.port}/?query=value" + else: + expected["url"] = ( + f"http://127.0.0.1:{raw_server.port}/?query=%5BFiltered%5D" + ) + + assert crumb["data"] == ApproxDict(expected) + + @pytest.mark.asyncio async def test_outgoing_trace_headers(sentry_init, aiohttp_raw_server, aiohttp_client): sentry_init( From 8e3adaf74fde9b907c639a98df62c0903bdc63ae Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 13:13:45 +0200 Subject: [PATCH 14/26] exclude the spans --- sentry_sdk/tracing_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index c740397653..8a8824c554 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -229,7 +229,7 @@ def add_http_breadcrumb(status_code, data): def maybe_create_breadcrumbs_from_span( scope: "sentry_sdk.Scope", span: "sentry_sdk.tracing.Span" ) -> None: - if span.op == OP.HTTP_CLIENT: + if span.op == OP.HTTP_CLIENT and span.origin not in ("auto.http.aiohttp",): level = None status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE) if status_code: From 2a172c1143c662f588920a18708845d946ac6169 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 13:15:50 +0200 Subject: [PATCH 15/26] . --- tests/integrations/aiohttp/test_aiohttp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 4ce2b69647..c51b83d94c 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1069,7 +1069,7 @@ def fake_create_trace_context(*args, **kwargs): trace_context = create_trace_config() async def overwrite_timestamps(session, trace_config_ctx, params): - span = trace_config_ctx.span + span = trace_config_ctx._sentry_span span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) @@ -1128,7 +1128,7 @@ def fake_create_trace_context(*args, **kwargs): trace_context = create_trace_config() async def overwrite_timestamps(session, trace_config_ctx, params): - span = trace_config_ctx.span + span = trace_config_ctx._sentry_span span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) From 1239d84dd07cab78488a322e9f0774155431cac8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 13:19:03 +0200 Subject: [PATCH 16/26] . --- sentry_sdk/integrations/aiohttp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 0743849a70..f34b33eebc 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -461,6 +461,8 @@ async def on_request_start( legacy_span.set_data(SPANDATA.HTTP_METHOD, method) if parsed_url is not None: legacy_span.set_data("url", parsed_url.url) + legacy_span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + legacy_span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) breadcrumb.update( { SPANDATA.HTTP_QUERY: parsed_url.query, From abdc32ba9a4406d9616dc0cc43186bbd8764fa84 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 13:36:53 +0200 Subject: [PATCH 17/26] . --- tests/integrations/aiohttp/test_aiohttp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index c51b83d94c..c8b15d4f17 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -607,10 +607,14 @@ async def handler(request): if url_expected: if query_expected: expected["url"] = f"http://127.0.0.1:{raw_server.port}/?query=value" + expected["http.query"] = "query=value" else: expected["url"] = ( f"http://127.0.0.1:{raw_server.port}/?query=%5BFiltered%5D" ) + expected["http.query"] = "query=%5BFiltered%5D" + + assert crumb["data"] == ApproxDict(expected) @pytest.mark.parametrize( @@ -755,10 +759,12 @@ async def handler(request): if url_expected: if query_expected: expected["url"] = f"http://127.0.0.1:{raw_server.port}/?query=value" + expected["http.query"] = "query=value" else: expected["url"] = ( f"http://127.0.0.1:{raw_server.port}/?query=%5BFiltered%5D" ) + expected["http.query"] = "query=%5BFiltered%5D" assert crumb["data"] == ApproxDict(expected) From d03072b8fa181de04bc5a5e5ff6e9312ef872e8a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 14:28:27 +0200 Subject: [PATCH 18/26] . --- sentry_sdk/integrations/aiohttp.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index f34b33eebc..e142d3a131 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -396,7 +396,7 @@ async def on_request_start( parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, ) - span: "Union[Span, StreamedSpan, None]" + span: "Union[Span, StreamedSpan, None]" = None if has_span_streaming_enabled(client.options): attributes: "Attributes" = { "sentry.op": OP.HTTP_CLIENT, @@ -446,12 +446,10 @@ async def on_request_start( attributes["url.full"] = url_full breadcrumb["url"] = url_full - if sentry_sdk.traces.get_current_span() is None: - span = None - else: - span = sentry_sdk.traces.start_span( - name=span_name, attributes=attributes - ) + if sentry_sdk.traces.get_current_span() is not None: + span = sentry_sdk.traces.start_span( + name=span_name, attributes=attributes + ) else: legacy_span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, From 689d9d1848605d0275fe9c32ef7154c3cbb981df Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 08:31:37 +0200 Subject: [PATCH 19/26] fix type annotation --- sentry_sdk/tracing_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8a8824c554..480d36e2a1 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -210,8 +210,7 @@ def record_sql_queries( yield span -def add_http_breadcrumb(status_code, data): - # type: (Optional[int], dict[str, Any]) -> None +def add_http_breadcrumb(status_code: "Optional[int]", data: "dict[str, Any]") -> None: level = None if status_code: if 500 <= status_code <= 599: From 8bfb87981307c2a29a915c58a969b347299d045a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 08:40:12 +0200 Subject: [PATCH 20/26] defensive access --- sentry_sdk/integrations/aiohttp.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index e142d3a131..cf66b9db5a 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -500,7 +500,7 @@ async def on_request_end( ) -> None: status = int(params.response.status) - breadcrumb = trace_config_ctx._sentry_breadcrumb + breadcrumb = getattr(trace_config_ctx, "_sentry_breadcrumb", None) if breadcrumb is not None: breadcrumb.update( { @@ -515,11 +515,10 @@ async def on_request_end( breadcrumb, ) - if trace_config_ctx._sentry_span is None: + span = getattr(trace_config_ctx, "_sentry_span", None) + if span is None: return - span = trace_config_ctx._sentry_span - if isinstance(span, StreamedSpan): span.set_attribute("http.response.status_code", status) span.status = ( From 31486cf2c9f9a7da4a089f64fa20f4eea9037d27 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 10:40:36 +0200 Subject: [PATCH 21/26] ref(pyreqwest): Move crumbs to integration --- sentry_sdk/integrations/pyreqwest.py | 68 ++++++-- sentry_sdk/tracing_utils.py | 5 +- .../integrations/pyreqwest/test_pyreqwest.py | 150 +++++++++++++++++- 3 files changed, 211 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index d25d03f470..d8a5d636b7 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -9,6 +9,7 @@ from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( + add_http_breadcrumb, add_http_request_source, add_sentry_baggage_to_headers, has_span_streaming_enabled, @@ -67,15 +68,18 @@ def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> Non original_method = getattr(cls, method_name) def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": - if not getattr(self, "_sentry_instrumented", False): - integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration) - if integration is not None: - self.with_middleware(middleware) - try: - self._sentry_instrumented = True - except (TypeError, AttributeError): - # In case the instance itself is immutable or doesn't allow extra attributes - pass + integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration) + + if getattr(self, "_sentry_instrumented", False) or integration is None: + return original_method(self, *args, **kwargs) + + self.with_middleware(middleware) + try: + self._sentry_instrumented = True + except (TypeError, AttributeError): + # In case the instance itself is immutable or doesn't allow extra attributes + pass + return original_method(self, *args, **kwargs) setattr(cls, method_name, sentry_patched_method) @@ -156,6 +160,13 @@ async def sentry_async_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return await next_handler.run(request) + method = request.method + parsed_url = None + with capture_internal_exceptions(): + # This needs to be done early because the URL is no longer accessible + # after the request has been sent + parsed_url = parse_url(str(request.url), sanitize=False) + with _sentry_pyreqwest_span(request) as span: response = await next_handler.run(request) if isinstance(span, StreamedSpan): @@ -167,6 +178,22 @@ async def sentry_async_middleware( elif span is not None: span.set_http_status(response.status) + breadcrumb_data = { + SPANDATA.HTTP_METHOD: method, + SPANDATA.HTTP_STATUS_CODE: response.status, + } + + if parsed_url and should_send_default_pii(): + breadcrumb_data.update( + { + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } + ) + + add_http_breadcrumb(response.status, breadcrumb_data) + return response @@ -176,6 +203,13 @@ def sentry_sync_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return next_handler.run(request) + method = request.method + parsed_url = None + with capture_internal_exceptions(): + # This needs to be done early because the URL is no longer accessible + # after the request has been sent + parsed_url = parse_url(str(request.url), sanitize=False) + with _sentry_pyreqwest_span(request) as span: response = next_handler.run(request) if isinstance(span, StreamedSpan): @@ -187,4 +221,20 @@ def sentry_sync_middleware( elif span is not None: span.set_http_status(response.status) + breadcrumb_data = { + SPANDATA.HTTP_METHOD: method, + SPANDATA.HTTP_STATUS_CODE: response.status, + } + + if parsed_url and should_send_default_pii(): + breadcrumb_data.update( + { + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } + ) + + add_http_breadcrumb(response.status, breadcrumb_data) + return response diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 480d36e2a1..7ec94b504d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -228,7 +228,10 @@ def add_http_breadcrumb(status_code: "Optional[int]", data: "dict[str, Any]") -> def maybe_create_breadcrumbs_from_span( scope: "sentry_sdk.Scope", span: "sentry_sdk.tracing.Span" ) -> None: - if span.op == OP.HTTP_CLIENT and span.origin not in ("auto.http.aiohttp",): + if span.op == OP.HTTP_CLIENT and span.origin not in ( + "auto.http.aiohttp", + "auto.http.pyreqwest", + ): level = None status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE) if status_code: diff --git a/tests/integrations/pyreqwest/test_pyreqwest.py b/tests/integrations/pyreqwest/test_pyreqwest.py index 05a96f8731..63553c809d 100644 --- a/tests/integrations/pyreqwest/test_pyreqwest.py +++ b/tests/integrations/pyreqwest/test_pyreqwest.py @@ -11,10 +11,10 @@ from pyreqwest.simple.sync_request import pyreqwest_get as sync_pyreqwest_get import sentry_sdk -from sentry_sdk import start_transaction +from sentry_sdk import capture_message, start_transaction from sentry_sdk.consts import MATCH_ALL, SPANDATA from sentry_sdk.integrations.pyreqwest import PyreqwestIntegration -from tests.conftest import get_free_port +from tests.conftest import ApproxDict, get_free_port class PyreqwestMockHandler(BaseHTTPRequestHandler): @@ -956,3 +956,149 @@ def fake_start_span(*args, **kwargs): assert SPANDATA.CODE_NAMESPACE in data assert SPANDATA.CODE_FILEPATH in data assert SPANDATA.CODE_FUNCTION in data + + +@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_crumb_capture( + sentry_init, + capture_events, + server_port, + send_default_pii, + span_streaming, +): + def before_breadcrumb(crumb, hint): + crumb["data"]["extra"] = "foo" + return crumb + + sentry_init( + integrations=[PyreqwestIntegration()], + before_breadcrumb=before_breadcrumb, + send_default_pii=send_default_pii, + trace_lifecycle="stream" if span_streaming else "static", + ) + + url = f"http://localhost:{server_port}/hello?q=test#frag" + + events = capture_events() + + client = SyncClientBuilder().build() + response = client.get(url).build().send() + assert response.status == 200 + + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + expected = { + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + "extra": "foo", + } + if send_default_pii: + expected["url"] = f"http://localhost:{server_port}/hello" + expected[SPANDATA.HTTP_QUERY] = "q=test" + expected[SPANDATA.HTTP_FRAGMENT] = "frag" + + assert crumb["data"] == ApproxDict(expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_async_crumb_capture( + sentry_init, + capture_events, + server_port, + send_default_pii, + span_streaming, +): + sentry_init( + integrations=[PyreqwestIntegration()], + send_default_pii=send_default_pii, + trace_lifecycle="stream" if span_streaming else "static", + ) + + url = f"http://localhost:{server_port}/hello?q=test#frag" + + events = capture_events() + + async with ClientBuilder().build() as client: + response = await client.get(url).build().send() + assert response.status == 200 + + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + expected = { + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + } + if send_default_pii: + expected["url"] = f"http://localhost:{server_port}/hello" + expected[SPANDATA.HTTP_QUERY] = "q=test" + expected[SPANDATA.HTTP_FRAGMENT] = "frag" + + assert crumb["data"] == ApproxDict(expected) + + +@pytest.mark.parametrize( + "status_code,level", + [ + (200, None), + (301, None), + (403, "warning"), + (405, "warning"), + (500, "error"), + ], +) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_crumb_capture_client_error( + sentry_init, + capture_events, + server_port, + status_code, + level, + span_streaming, +): + sentry_init( + integrations=[PyreqwestIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + ) + + url = f"http://localhost:{server_port}/status/{status_code}" + + events = capture_events() + + client = SyncClientBuilder().build() + response = client.get(url).build().send() + assert response.status == status_code + + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + if level is None: + assert "level" not in crumb + else: + assert crumb["level"] == level + + assert crumb["data"] == ApproxDict( + { + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: status_code, + } + ) From ccf1de940432fe0d767dc14a7993b995a554c163 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 11:06:53 +0200 Subject: [PATCH 22/26] make it work in async --- sentry_sdk/integrations/pyreqwest.py | 22 +++++++++++++++++++--- sentry_sdk/tracing_utils.py | 12 ++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index d8a5d636b7..5338e7d025 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -1,3 +1,4 @@ +import inspect from contextlib import contextmanager from typing import Any, Generator @@ -67,13 +68,26 @@ def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> Non original_method = getattr(cls, method_name) + is_async = inspect.iscoroutinefunction(middleware) + def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration) if getattr(self, "_sentry_instrumented", False) or integration is None: return original_method(self, *args, **kwargs) - self.with_middleware(middleware) + if is_async: + isolation_scope = sentry_sdk.get_isolation_scope() + + async def bound_middleware( + request: "Request", next_handler: "Next" + ) -> "Response": + return await middleware(request, next_handler, isolation_scope) + + self.with_middleware(bound_middleware) + else: + self.with_middleware(middleware) + try: self._sentry_instrumented = True except (TypeError, AttributeError): @@ -155,7 +169,9 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": async def sentry_async_middleware( - request: "Request", next_handler: "Next" + request: "Request", + next_handler: "Next", + isolation_scope: "sentry_sdk.Scope", ) -> "Response": if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return await next_handler.run(request) @@ -192,7 +208,7 @@ async def sentry_async_middleware( } ) - add_http_breadcrumb(response.status, breadcrumb_data) + add_http_breadcrumb(response.status, breadcrumb_data, isolation_scope) return response diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 7ec94b504d..62151abd18 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -43,6 +43,7 @@ from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union from sentry_sdk._types import Attributes + from sentry_sdk.scope import Scope SENTRY_TRACE_REGEX = re.compile( @@ -210,7 +211,11 @@ def record_sql_queries( yield span -def add_http_breadcrumb(status_code: "Optional[int]", data: "dict[str, Any]") -> None: +def add_http_breadcrumb( + status_code: "Optional[int]", + data: "dict[str, Any]", + scope: "Optional[Scope]" = None, +) -> None: level = None if status_code: if 500 <= status_code <= 599: @@ -222,7 +227,10 @@ def add_http_breadcrumb(status_code: "Optional[int]", data: "dict[str, Any]") -> if level: kwargs["level"] = level - sentry_sdk.add_breadcrumb(**kwargs) + if scope is not None: + scope.add_breadcrumb(**kwargs) + else: + sentry_sdk.add_breadcrumb(**kwargs) def maybe_create_breadcrumbs_from_span( From 0de51ade87645b5af8ca915edf21a4bc7db3d2f9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 11:09:26 +0200 Subject: [PATCH 23/26] fix sphinx --- sentry_sdk/tracing_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 62151abd18..9568bb1ec1 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -43,7 +43,6 @@ from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union from sentry_sdk._types import Attributes - from sentry_sdk.scope import Scope SENTRY_TRACE_REGEX = re.compile( @@ -214,7 +213,7 @@ def record_sql_queries( def add_http_breadcrumb( status_code: "Optional[int]", data: "dict[str, Any]", - scope: "Optional[Scope]" = None, + scope: "Optional[sentry_sdk.Scope]" = None, ) -> None: level = None if status_code: From 1bdf216515c6d5ad0948d6729441f971fb7ef17f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 11:25:38 +0200 Subject: [PATCH 24/26] gate no response --- sentry_sdk/integrations/pyreqwest.py | 62 +++++++++++++++------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index 5338e7d025..dafec0131d 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -183,6 +183,7 @@ async def sentry_async_middleware( # after the request has been sent parsed_url = parse_url(str(request.url), sanitize=False) + response = None with _sentry_pyreqwest_span(request) as span: response = await next_handler.run(request) if isinstance(span, StreamedSpan): @@ -194,21 +195,22 @@ async def sentry_async_middleware( elif span is not None: span.set_http_status(response.status) - breadcrumb_data = { - SPANDATA.HTTP_METHOD: method, - SPANDATA.HTTP_STATUS_CODE: response.status, - } - - if parsed_url and should_send_default_pii(): - breadcrumb_data.update( - { - "url": parsed_url.url, - SPANDATA.HTTP_QUERY: parsed_url.query, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, - } - ) + if response is not None: + breadcrumb_data = { + SPANDATA.HTTP_METHOD: method, + SPANDATA.HTTP_STATUS_CODE: response.status, + } + + if parsed_url and should_send_default_pii(): + breadcrumb_data.update( + { + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } + ) - add_http_breadcrumb(response.status, breadcrumb_data, isolation_scope) + add_http_breadcrumb(response.status, breadcrumb_data, isolation_scope) return response @@ -226,6 +228,7 @@ def sentry_sync_middleware( # after the request has been sent parsed_url = parse_url(str(request.url), sanitize=False) + response = None with _sentry_pyreqwest_span(request) as span: response = next_handler.run(request) if isinstance(span, StreamedSpan): @@ -237,20 +240,21 @@ def sentry_sync_middleware( elif span is not None: span.set_http_status(response.status) - breadcrumb_data = { - SPANDATA.HTTP_METHOD: method, - SPANDATA.HTTP_STATUS_CODE: response.status, - } - - if parsed_url and should_send_default_pii(): - breadcrumb_data.update( - { - "url": parsed_url.url, - SPANDATA.HTTP_QUERY: parsed_url.query, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, - } - ) - - add_http_breadcrumb(response.status, breadcrumb_data) + if response is not None: + breadcrumb_data = { + SPANDATA.HTTP_METHOD: method, + SPANDATA.HTTP_STATUS_CODE: response.status, + } + + if parsed_url and should_send_default_pii(): + breadcrumb_data.update( + { + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } + ) + + add_http_breadcrumb(response.status, breadcrumb_data) return response From 579d2248f3b94f1f2a114d6b3fde356dd685f51b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 10 Aug 2026 12:17:47 +0200 Subject: [PATCH 25/26] simplify --- sentry_sdk/integrations/pyreqwest.py | 18 +-- sentry_sdk/tracing_utils.py | 11 +- .../integrations/pyreqwest/test_pyreqwest.py | 122 ++++++++++++++++-- 3 files changed, 113 insertions(+), 38 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index dafec0131d..098a625d7e 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -1,4 +1,3 @@ -import inspect from contextlib import contextmanager from typing import Any, Generator @@ -68,25 +67,13 @@ def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> Non original_method = getattr(cls, method_name) - is_async = inspect.iscoroutinefunction(middleware) - def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration) if getattr(self, "_sentry_instrumented", False) or integration is None: return original_method(self, *args, **kwargs) - if is_async: - isolation_scope = sentry_sdk.get_isolation_scope() - - async def bound_middleware( - request: "Request", next_handler: "Next" - ) -> "Response": - return await middleware(request, next_handler, isolation_scope) - - self.with_middleware(bound_middleware) - else: - self.with_middleware(middleware) + self.with_middleware(middleware) try: self._sentry_instrumented = True @@ -171,7 +158,6 @@ def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]": async def sentry_async_middleware( request: "Request", next_handler: "Next", - isolation_scope: "sentry_sdk.Scope", ) -> "Response": if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return await next_handler.run(request) @@ -210,7 +196,7 @@ async def sentry_async_middleware( } ) - add_http_breadcrumb(response.status, breadcrumb_data, isolation_scope) + add_http_breadcrumb(response.status, breadcrumb_data) return response diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 9568bb1ec1..7ec94b504d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -210,11 +210,7 @@ def record_sql_queries( yield span -def add_http_breadcrumb( - status_code: "Optional[int]", - data: "dict[str, Any]", - scope: "Optional[sentry_sdk.Scope]" = None, -) -> None: +def add_http_breadcrumb(status_code: "Optional[int]", data: "dict[str, Any]") -> None: level = None if status_code: if 500 <= status_code <= 599: @@ -226,10 +222,7 @@ def add_http_breadcrumb( if level: kwargs["level"] = level - if scope is not None: - scope.add_breadcrumb(**kwargs) - else: - sentry_sdk.add_breadcrumb(**kwargs) + sentry_sdk.add_breadcrumb(**kwargs) def maybe_create_breadcrumbs_from_span( diff --git a/tests/integrations/pyreqwest/test_pyreqwest.py b/tests/integrations/pyreqwest/test_pyreqwest.py index 63553c809d..b31f717e6d 100644 --- a/tests/integrations/pyreqwest/test_pyreqwest.py +++ b/tests/integrations/pyreqwest/test_pyreqwest.py @@ -1009,27 +1009,31 @@ def before_breadcrumb(crumb, hint): @pytest.mark.asyncio @pytest.mark.parametrize("send_default_pii", [True, False]) -@pytest.mark.parametrize("span_streaming", [True, False]) async def test_async_crumb_capture( sentry_init, capture_events, server_port, send_default_pii, - span_streaming, ): sentry_init( integrations=[PyreqwestIntegration()], send_default_pii=send_default_pii, - trace_lifecycle="stream" if span_streaming else "static", ) url = f"http://localhost:{server_port}/hello?q=test#frag" events = capture_events() - async with ClientBuilder().build() as client: - response = await client.get(url).build().send() - assert response.status == 200 + # Ensure the isolation scope contextvar is set before pyreqwest spawns + # its middleware on a separate asyncio Task. Without this, the child task + # lazily creates its own isolation scope, and breadcrumbs added there + # don't propagate back to this task's context. + sentry_sdk.get_isolation_scope() + + with sentry_sdk.start_transaction(): + async with ClientBuilder().build() as client: + response = await client.get(url).build().send() + assert response.status == 200 capture_message("Testing!") @@ -1051,6 +1055,49 @@ async def test_async_crumb_capture( assert crumb["data"] == ApproxDict(expected) +@pytest.mark.asyncio +@pytest.mark.parametrize("send_default_pii", [True, False]) +async def test_async_crumb_capture_span_streaming( + sentry_init, + capture_events, + server_port, + send_default_pii, +): + sentry_init( + integrations=[PyreqwestIntegration()], + send_default_pii=send_default_pii, + trace_lifecycle="stream", + ) + + url = f"http://localhost:{server_port}/hello?q=test#frag" + + events = capture_events() + + with sentry_sdk.traces.start_span(name="segment"): + async with ClientBuilder().build() as client: + response = await client.get(url).build().send() + assert response.status == 200 + + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + expected = { + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + } + if send_default_pii: + expected["url"] = f"http://localhost:{server_port}/hello" + expected[SPANDATA.HTTP_QUERY] = "q=test" + expected[SPANDATA.HTTP_FRAGMENT] = "frag" + + assert crumb["data"] == ApproxDict(expected) + + @pytest.mark.parametrize( "status_code,level", [ @@ -1061,29 +1108,78 @@ async def test_async_crumb_capture( (500, "error"), ], ) -@pytest.mark.parametrize("span_streaming", [True, False]) def test_crumb_capture_client_error( sentry_init, capture_events, server_port, status_code, level, - span_streaming, ): sentry_init( integrations=[PyreqwestIntegration()], - trace_lifecycle="stream" if span_streaming else "static", ) url = f"http://localhost:{server_port}/status/{status_code}" events = capture_events() - client = SyncClientBuilder().build() - response = client.get(url).build().send() - assert response.status == status_code + with sentry_sdk.start_transaction(): + client = SyncClientBuilder().build() + response = client.get(url).build().send() + assert response.status == status_code - capture_message("Testing!") + capture_message("Testing!") + + (event,) = events + + crumb = event["breadcrumbs"]["values"][0] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + if level is None: + assert "level" not in crumb + else: + assert crumb["level"] == level + + assert crumb["data"] == ApproxDict( + { + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: status_code, + } + ) + + +@pytest.mark.parametrize( + "status_code,level", + [ + (200, None), + (301, None), + (403, "warning"), + (405, "warning"), + (500, "error"), + ], +) +def test_crumb_capture_client_error_span_streaming( + sentry_init, + capture_events, + server_port, + status_code, + level, +): + sentry_init( + integrations=[PyreqwestIntegration()], + ) + + url = f"http://localhost:{server_port}/status/{status_code}" + + events = capture_events() + + with sentry_sdk.traces.start_span(name="segment"): + client = SyncClientBuilder().build() + response = client.get(url).build().send() + assert response.status == status_code + + capture_message("Testing!") (event,) = events From 70ad7a9242c98e69305a9d8b29485677ad3c5232 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 12 Aug 2026 13:52:34 +0200 Subject: [PATCH 26/26] fix test --- tests/integrations/pyreqwest/test_pyreqwest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/pyreqwest/test_pyreqwest.py b/tests/integrations/pyreqwest/test_pyreqwest.py index b31f717e6d..7bf2fb09ba 100644 --- a/tests/integrations/pyreqwest/test_pyreqwest.py +++ b/tests/integrations/pyreqwest/test_pyreqwest.py @@ -1168,6 +1168,7 @@ def test_crumb_capture_client_error_span_streaming( ): sentry_init( integrations=[PyreqwestIntegration()], + trace_lifecycle="stream", ) url = f"http://localhost:{server_port}/status/{status_code}"