From 2e4c44098a7126870d6174c177d5447b7cb75364 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 10:23:57 +0200 Subject: [PATCH 1/4] ref: Move HTTP client breadcrumbs to integrations 1 --- sentry_sdk/integrations/aiohttp.py | 15 ++++++++++++ sentry_sdk/integrations/boto3.py | 16 ++++++++++++- sentry_sdk/integrations/httpx.py | 27 +++++++++++++++++++++ sentry_sdk/integrations/httpx2.py | 27 +++++++++++++++++++++ sentry_sdk/integrations/pyreqwest.py | 35 ++++++++++++++++++++++++++++ sentry_sdk/tracing_utils.py | 15 ++++++++++++ 6 files changed, 134 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 858bf273f2..10ad3d6e19 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, @@ -504,6 +505,20 @@ async def on_request_end( with capture_internal_exceptions(): add_http_request_source(span) + with capture_internal_exceptions(): + parsed_url = parse_url(str(params.url), sanitize=False) + add_http_breadcrumb( + status, + { + SPANDATA.HTTP_METHOD: params.method.upper(), + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: status, + "reason": params.response.reason, + }, + ) + trace_config = TraceConfig() trace_config.on_request_start.append(on_request_start) diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index 69deefc7b7..ade8fc0016 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -7,7 +7,7 @@ from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import Span -from sentry_sdk.tracing_utils import has_span_streaming_enabled +from sentry_sdk.tracing_utils import add_http_breadcrumb, has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, parse_url, @@ -112,18 +112,32 @@ def _sentry_request_created( # request.context is an open-ended data-structure # where we can add anything useful in request life cycle. request.context["_sentrysdk_span"] = span + request.context["_sentrysdk_breadcrumb_data"] = { + SPANDATA.HTTP_METHOD: request.method, + "url": request.url, + } def _sentry_after_call( context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any" ) -> None: span: "Optional[Union[Span, StreamedSpan]]" = context.pop("_sentrysdk_span", None) + breadcrumb_data: "Optional[Dict[str, Any]]" = context.pop( + "_sentrysdk_breadcrumb_data", None + ) # Span could be absent if the integration is disabled. if span is None: return span.__exit__(None, None, None) + with capture_internal_exceptions(): + status_code = parsed.get("ResponseMetadata", {}).get("HTTPStatusCode") + data = breadcrumb_data or {} + if status_code is not None: + data[SPANDATA.HTTP_STATUS_CODE] = status_code + add_http_breadcrumb(status_code, data) + body = parsed.get("Body") if not isinstance(body, StreamingBody): return diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 333754e209..fec6c1b75d 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -5,6 +5,7 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing_utils import ( + add_http_breadcrumb, add_http_request_source, has_span_streaming_enabled, propagate_trace_headers, @@ -128,6 +129,19 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": with capture_internal_exceptions(): add_http_request_source(span) + with capture_internal_exceptions(): + add_http_breadcrumb( + rv.status_code, + { + SPANDATA.HTTP_METHOD: request.method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + }, + ) + return rv Client.send = send # type: ignore @@ -220,6 +234,19 @@ async def send( with capture_internal_exceptions(): add_http_request_source(span) + with capture_internal_exceptions(): + add_http_breadcrumb( + rv.status_code, + { + SPANDATA.HTTP_METHOD: request.method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + }, + ) + return rv AsyncClient.send = send # type: ignore diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index b658ce8ba4..4a6dabbdc0 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -5,6 +5,7 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing_utils import ( + add_http_breadcrumb, add_http_request_source, has_span_streaming_enabled, propagate_trace_headers, @@ -129,6 +130,19 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": with capture_internal_exceptions(): add_http_request_source(span) + with capture_internal_exceptions(): + add_http_breadcrumb( + rv.status_code, + { + SPANDATA.HTTP_METHOD: request.method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + }, + ) + return rv Client.send = send # type: ignore @@ -222,6 +236,19 @@ async def send( with capture_internal_exceptions(): add_http_request_source(span) + with capture_internal_exceptions(): + add_http_breadcrumb( + rv.status_code, + { + SPANDATA.HTTP_METHOD: request.method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + }, + ) + return rv AsyncClient.send = send # type: ignore diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index d25d03f470..15dd345848 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, @@ -156,6 +157,11 @@ async def sentry_async_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return await next_handler.run(request) + parsed_url = None + with capture_internal_exceptions(): + parsed_url = parse_url(str(request.url), sanitize=False) + method = request.method + with _sentry_pyreqwest_span(request) as span: response = await next_handler.run(request) if isinstance(span, StreamedSpan): @@ -167,6 +173,18 @@ async def sentry_async_middleware( elif span is not None: span.set_http_status(response.status) + with capture_internal_exceptions(): + add_http_breadcrumb( + response.status, + { + SPANDATA.HTTP_METHOD: method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: response.status, + }, + ) + return response @@ -176,6 +194,11 @@ def sentry_sync_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return next_handler.run(request) + parsed_url = None + with capture_internal_exceptions(): + parsed_url = parse_url(str(request.url), sanitize=False) + method = request.method + with _sentry_pyreqwest_span(request) as span: response = next_handler.run(request) if isinstance(span, StreamedSpan): @@ -187,4 +210,16 @@ def sentry_sync_middleware( elif span is not None: span.set_http_status(response.status) + with capture_internal_exceptions(): + add_http_breadcrumb( + response.status, + { + SPANDATA.HTTP_METHOD: method, + "url": parsed_url.url if parsed_url else None, + SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, + SPANDATA.HTTP_STATUS_CODE: response.status, + }, + ) + return response diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index b3658f6f91..bc286be2ef 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -210,6 +210,21 @@ 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 = {"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: From 59c9696388fbafb3c38cfacc32e79bf652175199 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 11:10:05 +0200 Subject: [PATCH 2/4] . --- sentry_sdk/integrations/aiohttp.py | 22 +++++++----- sentry_sdk/integrations/httpx.py | 44 ++++++++++++----------- sentry_sdk/integrations/httpx2.py | 44 ++++++++++++----------- sentry_sdk/integrations/pyreqwest.py | 52 ++++++++++++++++------------ sentry_sdk/tracing_utils.py | 1 + 5 files changed, 93 insertions(+), 70 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 10ad3d6e19..e396a29c4c 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -507,16 +507,22 @@ async def on_request_end( with capture_internal_exceptions(): parsed_url = parse_url(str(params.url), sanitize=False) + breadcrumb_data = { + SPANDATA.HTTP_METHOD: params.method.upper(), + SPANDATA.HTTP_STATUS_CODE: status, + "reason": params.response.reason, + } + if parsed_url: + breadcrumb_data.update( + { + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } + ) add_http_breadcrumb( status, - { - SPANDATA.HTTP_METHOD: params.method.upper(), - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: status, - "reason": params.response.reason, - }, + breadcrumb_data, ) trace_config = TraceConfig() diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index fec6c1b75d..7c0f06dab0 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -129,18 +129,20 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": with capture_internal_exceptions(): add_http_request_source(span) - with capture_internal_exceptions(): - add_http_breadcrumb( - rv.status_code, + breadcrumb_data = { + SPANDATA.HTTP_METHOD: request.method, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + } + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: request.method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: rv.status_code, - "reason": rv.reason_phrase, - }, + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } ) + add_http_breadcrumb(rv.status_code, breadcrumb_data) return rv @@ -234,18 +236,20 @@ async def send( with capture_internal_exceptions(): add_http_request_source(span) - with capture_internal_exceptions(): - add_http_breadcrumb( - rv.status_code, + breadcrumb_data = { + SPANDATA.HTTP_METHOD: request.method, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + } + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: request.method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: rv.status_code, - "reason": rv.reason_phrase, - }, + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } ) + add_http_breadcrumb(rv.status_code, breadcrumb_data) return rv diff --git a/sentry_sdk/integrations/httpx2.py b/sentry_sdk/integrations/httpx2.py index 4a6dabbdc0..fad72deec9 100644 --- a/sentry_sdk/integrations/httpx2.py +++ b/sentry_sdk/integrations/httpx2.py @@ -130,18 +130,20 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": with capture_internal_exceptions(): add_http_request_source(span) - with capture_internal_exceptions(): - add_http_breadcrumb( - rv.status_code, + breadcrumb_data = { + SPANDATA.HTTP_METHOD: request.method, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + } + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: request.method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: rv.status_code, - "reason": rv.reason_phrase, - }, + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } ) + add_http_breadcrumb(rv.status_code, breadcrumb_data) return rv @@ -236,18 +238,20 @@ async def send( with capture_internal_exceptions(): add_http_request_source(span) - with capture_internal_exceptions(): - add_http_breadcrumb( - rv.status_code, + breadcrumb_data = { + SPANDATA.HTTP_METHOD: request.method, + SPANDATA.HTTP_STATUS_CODE: rv.status_code, + "reason": rv.reason_phrase, + } + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: request.method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: rv.status_code, - "reason": rv.reason_phrase, - }, + "url": parsed_url.url, + SPANDATA.HTTP_QUERY: parsed_url.query, + SPANDATA.HTTP_FRAGMENT: parsed_url.fragment, + } ) + add_http_breadcrumb(rv.status_code, breadcrumb_data) return rv diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index 15dd345848..d642131445 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -157,9 +157,6 @@ async def sentry_async_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return await next_handler.run(request) - parsed_url = None - with capture_internal_exceptions(): - parsed_url = parse_url(str(request.url), sanitize=False) method = request.method with _sentry_pyreqwest_span(request) as span: @@ -173,18 +170,25 @@ 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, + } + + parsed_url = None with capture_internal_exceptions(): - add_http_breadcrumb( - response.status, + parsed_url = parse_url(str(request.url), sanitize=False) + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: response.status, - }, + "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 @@ -194,9 +198,6 @@ def sentry_sync_middleware( if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None: return next_handler.run(request) - parsed_url = None - with capture_internal_exceptions(): - parsed_url = parse_url(str(request.url), sanitize=False) method = request.method with _sentry_pyreqwest_span(request) as span: @@ -210,16 +211,23 @@ 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, + } + + parsed_url = None with capture_internal_exceptions(): - add_http_breadcrumb( - response.status, + parsed_url = parse_url(str(request.url), sanitize=False) + if parsed_url: + breadcrumb_data.update( { - SPANDATA.HTTP_METHOD: method, - "url": parsed_url.url if parsed_url else None, - SPANDATA.HTTP_QUERY: parsed_url.query if parsed_url else None, - SPANDATA.HTTP_FRAGMENT: parsed_url.fragment if parsed_url else None, - SPANDATA.HTTP_STATUS_CODE: response.status, - }, + "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 bc286be2ef..de4d73a8c7 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -222,6 +222,7 @@ def add_http_breadcrumb(status_code, data): kwargs = {"type": "http", "category": "httplib", "data": data} if level: kwargs["level"] = level + sentry_sdk.add_breadcrumb(**kwargs) From 12730c2c170bda141e8c16645496e5399ae14138 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 11:15:11 +0200 Subject: [PATCH 3/4] . --- sentry_sdk/integrations/pyreqwest.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/pyreqwest.py b/sentry_sdk/integrations/pyreqwest.py index d642131445..2a81b7afe7 100644 --- a/sentry_sdk/integrations/pyreqwest.py +++ b/sentry_sdk/integrations/pyreqwest.py @@ -159,6 +159,12 @@ async def sentry_async_middleware( method = request.method + # If we want to access request.url, we need to do it early. It can't be + # retrieved after the request has been sent + parsed_url = None + with capture_internal_exceptions(): + 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): @@ -174,10 +180,6 @@ async def sentry_async_middleware( SPANDATA.HTTP_METHOD: method, SPANDATA.HTTP_STATUS_CODE: response.status, } - - parsed_url = None - with capture_internal_exceptions(): - parsed_url = parse_url(str(request.url), sanitize=False) if parsed_url: breadcrumb_data.update( { @@ -200,6 +202,12 @@ def sentry_sync_middleware( method = request.method + # If we want to access request.url, we need to do it early. It can't be + # retrieved after the request has been sent + parsed_url = None + with capture_internal_exceptions(): + 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): @@ -216,9 +224,6 @@ def sentry_sync_middleware( SPANDATA.HTTP_STATUS_CODE: response.status, } - parsed_url = None - with capture_internal_exceptions(): - parsed_url = parse_url(str(request.url), sanitize=False) if parsed_url: breadcrumb_data.update( { From f6a9c0c5f036e49483d184dfd1d545fc0e9b31f1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 7 Aug 2026 11:38:41 +0200 Subject: [PATCH 4/4] mypy --- 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 de4d73a8c7..c740397653 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -219,7 +219,7 @@ def add_http_breadcrumb(status_code, data): elif 400 <= status_code <= 499: level = "warning" - kwargs = {"type": "http", "category": "httplib", "data": data} + kwargs: "dict[str, Any]" = {"type": "http", "category": "httplib", "data": data} if level: kwargs["level"] = level