Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
07a3ea3
ref(subprocess): Create breadcrumbs directly in integration
sentrivana Aug 7, 2026
2f91234
.
sentrivana Aug 7, 2026
c3aea86
ref: Move Redis breadcrumbs to integration
sentrivana Aug 7, 2026
15d2e2f
.
sentrivana Aug 7, 2026
53ab7f0
really mypy?
sentrivana Aug 7, 2026
0ea13df
.
sentrivana Aug 7, 2026
2f54487
move even earlier
sentrivana Aug 7, 2026
8e6087f
.
sentrivana Aug 7, 2026
91fe6a4
.
sentrivana Aug 7, 2026
5858c87
.
sentrivana Aug 7, 2026
906838b
Merge branch 'master' into ivana/move-breadcrumbs-to-integrations
sentrivana Aug 7, 2026
4e093aa
.
sentrivana Aug 7, 2026
040f223
Merge branch 'ivana/move-breadcrumbs-to-integrations' into ivana/move…
sentrivana Aug 7, 2026
7472af9
remove extra guards
sentrivana Aug 7, 2026
8ace993
Merge branch 'master' into ivana/move-redis-breadcrumbs-to-integration
sentrivana Aug 7, 2026
f492ba4
ref(aiohttp): Move breadcrumb capture to integration
sentrivana Aug 7, 2026
8e3adaf
exclude the spans
sentrivana Aug 7, 2026
2a172c1
.
sentrivana Aug 7, 2026
1239d84
.
sentrivana Aug 7, 2026
9adbede
Merge branch 'master' into ivana/move-http-crumbs-1
sentrivana Aug 7, 2026
abdc32b
.
sentrivana Aug 7, 2026
d03072b
.
sentrivana Aug 7, 2026
689d9d1
fix type annotation
sentrivana Aug 10, 2026
8bfb879
defensive access
sentrivana Aug 10, 2026
405fb12
Merge branch 'master' into ivana/move-http-crumbs-1
sentrivana Aug 10, 2026
31486cf
ref(pyreqwest): Move crumbs to integration
sentrivana Aug 10, 2026
ccf1de9
make it work in async
sentrivana Aug 10, 2026
0de51ad
fix sphinx
sentrivana Aug 10, 2026
1bdf216
gate no response
sentrivana Aug 10, 2026
579d224
simplify
sentrivana Aug 10, 2026
58c435a
Merge branch 'master' into ivana/move-http-crumbs-1
sentrivana Aug 10, 2026
e31a3aa
Merge branch 'ivana/move-http-crumbs-1' into ivana/move-http-crumbs-2
sentrivana Aug 10, 2026
70ad7a9
fix test
sentrivana Aug 12, 2026
a3cdae1
Merge branch 'master' into ivana/move-http-crumbs-2
sentrivana Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 66 additions & 10 deletions sentry_sdk/integrations/pyreqwest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Check warning on line 12 in sentry_sdk/integrations/pyreqwest.py

View check run for this annotation

@sentry/warden / warden: find-bugs

pyreqwest middleware skips breadcrumbs when HTTP requests raise exceptions

If `next_handler.run(request)` raises an exception (network error, timeout), the middleware skips breadcrumb creation because it only runs inside `if response is not None:`. The old fallback via `Span.finish()` and `maybe_create_breadcrumbs_from_span` no longer applies since `tracing_utils.py` now explicitly excludes pyreqwest (`"auto.http.pyreqwest"`) from that path. As a result, connection-level failures produce no HTTP breadcrumb at all. Both `sentry_async_middleware` (~172) and `sentry_sync_middleware` (~217) share this flaw.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyreqwest middleware skips breadcrumbs when HTTP requests raise exceptions

If next_handler.run(request) raises an exception (network error, timeout), the middleware skips breadcrumb creation because it only runs inside if response is not None:. The old fallback via Span.finish() and maybe_create_breadcrumbs_from_span no longer applies since tracing_utils.py now explicitly excludes pyreqwest ("auto.http.pyreqwest") from that path. As a result, connection-level failures produce no HTTP breadcrumb at all. Both sentry_async_middleware (~172) and sentry_sync_middleware (~217) share this flaw.

Evidence
  • sentry_async_middleware (line 172) and sentry_sync_middleware (line 217) set response = None, execute the request inside with _sentry_pyreqwest_span(...), and only call add_http_breadcrumb inside if response is not None: after the block.
  • If next_handler.run(request) raises, control jumps past the crumb code and response stays None.
  • maybe_create_breadcrumbs_from_span in tracing_utils.py:228-246 now skips span.origin == "auto.http.pyreqwest", so no fallback breadcrumb is recorded.
  • StreamedSpan.__exit__ marks the span as error but never creates a breadcrumb, and the old Span.finish() breadcrumb path is likewise blocked for this origin.
  • The new test suite (test_pyreqwest.py) includes happy-path breadcrumb tests but no test simulating a connection-level exception.

Identified by Warden · find-bugs · CGK-YBX

add_http_request_source,
add_sentry_baggage_to_headers,
has_span_streaming_enabled,
Expand Down Expand Up @@ -67,15 +68,19 @@
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)
Comment thread
sentrivana marked this conversation as resolved.

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)
Expand Down Expand Up @@ -151,11 +156,20 @@


async def sentry_async_middleware(
request: "Request", next_handler: "Next"
request: "Request",
next_handler: "Next",
) -> "Response":
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)

response = None
with _sentry_pyreqwest_span(request) as span:
response = await next_handler.run(request)
if isinstance(span, StreamedSpan):
Expand All @@ -167,6 +181,23 @@
elif span is not None:
span.set_http_status(response.status)

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(
Comment thread
sentrivana marked this conversation as resolved.
{
"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


Expand All @@ -176,6 +207,14 @@
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)

response = None
with _sentry_pyreqwest_span(request) as span:
response = next_handler.run(request)
if isinstance(span, StreamedSpan):
Expand All @@ -187,4 +226,21 @@
elif span is not None:
span.set_http_status(response.status)

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
5 changes: 4 additions & 1 deletion sentry_sdk/tracing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
247 changes: 245 additions & 2 deletions tests/integrations/pyreqwest/test_pyreqwest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -956,3 +956,246 @@ 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])
async def test_async_crumb_capture(
sentry_init,
capture_events,
server_port,
send_default_pii,
):
sentry_init(
integrations=[PyreqwestIntegration()],
send_default_pii=send_default_pii,
)

url = f"http://localhost:{server_port}/hello?q=test#frag"

events = capture_events()

# 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!")

(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.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",
[
(200, None),
(301, None),
(403, "warning"),
(405, "warning"),
(500, "error"),
],
)
def test_crumb_capture_client_error(
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.start_transaction():
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,
}
)


@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()],
trace_lifecycle="stream",
)
Comment thread
cursor[bot] marked this conversation as resolved.

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

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,
}
)
Loading