Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
62ca144
ref(httpx,httpx2): Move crumbs to integrations
sentrivana Aug 10, 2026
86c8163
.
sentrivana Aug 10, 2026
579d224
simplify
sentrivana Aug 10, 2026
3e992fb
Merge branch 'ivana/move-http-crumbs-2' into ivana/move-http-crumbs-3
sentrivana Aug 10, 2026
8ca14b3
.
sentrivana Aug 10, 2026
720071a
.
sentrivana Aug 10, 2026
f1312f7
.
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
e32ac38
Merge branch 'ivana/move-http-crumbs-2' into ivana/move-http-crumbs-3
sentrivana Aug 10, 2026
9c2b924
tests
sentrivana Aug 10, 2026
0d6d54d
Merge branch 'master' into ivana/move-http-crumbs-3
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
41 changes: 41 additions & 0 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions sentry_sdk/integrations/httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -128,6 +129,23 @@
with capture_internal_exceptions():
add_http_request_source(span)

breadcrumb_data = {
SPANDATA.HTTP_METHOD: request.method,
SPANDATA.HTTP_STATUS_CODE: rv.status_code,
"reason": rv.reason_phrase,
}

if parsed_url and (not is_span_streaming_enabled or should_send_default_pii()):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The is_not_span_streaming_enabled part is there for continuity in transaction mode, where we don't care about should_send_default_pii before setting breadcrumb data.

breadcrumb_data.update(
{
"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

Client.send = send # type: ignore
Expand Down Expand Up @@ -220,6 +238,22 @@
with capture_internal_exceptions():
add_http_request_source(span)

breadcrumb_data = {
SPANDATA.HTTP_METHOD: request.method,
SPANDATA.HTTP_STATUS_CODE: rv.status_code,
"reason": rv.reason_phrase,
}
if parsed_url and (not is_span_streaming_enabled or 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(rv.status_code, breadcrumb_data)

Check warning on line 255 in sentry_sdk/integrations/httpx.py

View check run for this annotation

@sentry/warden / warden: code-review

HTTPX breadcrumbs are silently skipped when the request raises

When `real_send` raises an exception, the new `add_http_breadcrumb` call after the span block is unreachable, so no HTTP breadcrumb is recorded for failed requests. Previously `Span.__exit__` created this crumb during cleanup regardless of exceptions.
Comment on lines +253 to +255

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.

HTTPX breadcrumbs are silently skipped when the request raises

When real_send raises an exception, the new add_http_breadcrumb call after the span block is unreachable, so no HTTP breadcrumb is recorded for failed requests. Previously Span.__exit__ created this crumb during cleanup regardless of exceptions.

Evidence
  • Span.__exit__ in tracing.py unconditionally calls self.finish(), which in turn calls maybe_create_breadcrumbs_from_span, so the old span-based mechanism always emitted a crumb—even when the request raised.
  • The new code places breadcrumb_data = { ... } and add_http_breadcrumb(...) after the if/else span blocks, so any exception propagated by real_send bypasses it entirely.
  • This means network errors, timeouts, or any other HTTP library exception inside the span block silently drop the breadcrumb, losing telemetry context that was previously available.

Identified by Warden · code-review · B7U-CFR


return rv

AsyncClient.send = send # type: ignore
33 changes: 33 additions & 0 deletions sentry_sdk/integrations/httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,6 +130,22 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
with capture_internal_exceptions():
add_http_request_source(span)

breadcrumb_data = {
SPANDATA.HTTP_METHOD: request.method,
SPANDATA.HTTP_STATUS_CODE: rv.status_code,
"reason": rv.reason_phrase,
}
if parsed_url and (not is_span_streaming_enabled or 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(rv.status_code, breadcrumb_data)

return rv

Client.send = send # type: ignore
Expand Down Expand Up @@ -222,6 +239,22 @@ async def send(
with capture_internal_exceptions():
add_http_request_source(span)

breadcrumb_data = {
SPANDATA.HTTP_METHOD: request.method,
SPANDATA.HTTP_STATUS_CODE: rv.status_code,
"reason": rv.reason_phrase,
}
if parsed_url and (not is_span_streaming_enabled or 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(rv.status_code, breadcrumb_data)

return rv

AsyncClient.send = send # type: ignore
2 changes: 2 additions & 0 deletions sentry_sdk/tracing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ def maybe_create_breadcrumbs_from_span(
if span.op == OP.HTTP_CLIENT and span.origin not in (
"auto.http.aiohttp",
"auto.http.pyreqwest",
"auto.http.httpx",
"auto.http.httpx2",
):
level = None
status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE)
Expand Down
242 changes: 241 additions & 1 deletion tests/integrations/httpx/test_httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ def before_breadcrumb(crumb, hint):
crumb["data"]["extra"] = "foo"
return crumb

sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb)
sentry_init(
integrations=[HttpxIntegration()],
before_breadcrumb=before_breadcrumb,
)

url = "http://example.com/"

Expand Down Expand Up @@ -48,6 +51,62 @@ def before_breadcrumb(crumb, hint):
)


@pytest.mark.parametrize("send_default_pii", [True, False])
def test_crumb_capture_and_hint_sync_span_streaming(
sentry_init, capture_events, httpx_mock, send_default_pii
):
httpx_mock.add_response()

def before_breadcrumb(crumb, hint):
crumb["data"]["extra"] = "foo"
return crumb

sentry_init(
integrations=[HttpxIntegration()],
before_breadcrumb=before_breadcrumb,
trace_lifecycle="stream",
send_default_pii=send_default_pii,
)

url = "http://example.com/"

with sentry_sdk.traces.start_span(name="segment"):
events = capture_events()

response = httpx.Client().get(url)

assert response.status_code == 200
capture_message("Testing!")

(event,) = events

crumb = event["breadcrumbs"]["values"][0]
assert crumb["type"] == "http"
assert crumb["category"] == "httplib"

if send_default_pii:
assert crumb["data"] == ApproxDict(
{
"url": url,
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_FRAGMENT: "",
SPANDATA.HTTP_QUERY: "",
SPANDATA.HTTP_STATUS_CODE: 200,
"reason": "OK",
"extra": "foo",
}
)
else:
assert crumb["data"] == ApproxDict(
{
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_STATUS_CODE: 200,
"reason": "OK",
"extra": "foo",
}
)


@pytest.mark.asyncio
async def test_crumb_capture_and_hint_async(sentry_init, capture_events, httpx_mock):
httpx_mock.add_response()
Expand Down Expand Up @@ -86,6 +145,62 @@ def before_breadcrumb(crumb, hint):
)


@pytest.mark.asyncio
@pytest.mark.parametrize("send_default_pii", [True, False])
async def test_crumb_capture_and_hint_async_span_streaming(
sentry_init, capture_events, httpx_mock, send_default_pii
):
httpx_mock.add_response()

def before_breadcrumb(crumb, hint):
crumb["data"]["extra"] = "foo"
return crumb

sentry_init(
integrations=[HttpxIntegration()],
before_breadcrumb=before_breadcrumb,
trace_lifecycle="stream",
send_default_pii=send_default_pii,
)

url = "http://example.com/"

with sentry_sdk.traces.start_span(name="segment"):
events = capture_events()

response = await httpx.AsyncClient().get(url)

assert response.status_code == 200
capture_message("Testing!")

(event,) = events

crumb = event["breadcrumbs"]["values"][0]
assert crumb["type"] == "http"
assert crumb["category"] == "httplib"
if send_default_pii:
assert crumb["data"] == ApproxDict(
{
"url": url,
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_FRAGMENT: "",
SPANDATA.HTTP_QUERY: "",
SPANDATA.HTTP_STATUS_CODE: 200,
"reason": "OK",
"extra": "foo",
}
)
else:
assert crumb["data"] == ApproxDict(
{
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_STATUS_CODE: 200,
"reason": "OK",
"extra": "foo",
}
)


@pytest.mark.parametrize(
"status_code,level",
[
Expand Down Expand Up @@ -135,6 +250,68 @@ def test_crumb_capture_client_error_sync(
)


@pytest.mark.parametrize(
"status_code,level",
[
(200, None),
(301, None),
(403, "warning"),
(405, "warning"),
(500, "error"),
],
)
@pytest.mark.parametrize("send_default_pii", [True, False])
def test_crumb_capture_client_error_sync_span_streaming(
sentry_init, capture_events, httpx_mock, status_code, level, send_default_pii
):
httpx_mock.add_response(status_code=status_code)

sentry_init(
integrations=[HttpxIntegration()],
trace_lifecycle="stream",
send_default_pii=send_default_pii,
)

url = "http://example.com/"

with sentry_sdk.traces.start_span(name="segment"):
events = capture_events()

response = httpx.Client().get(url)

assert response.status_code == 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

if send_default_pii:
assert crumb["data"] == ApproxDict(
{
"url": url,
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_FRAGMENT: "",
SPANDATA.HTTP_QUERY: "",
SPANDATA.HTTP_STATUS_CODE: status_code,
}
)
else:
assert crumb["data"] == ApproxDict(
{
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_STATUS_CODE: status_code,
}
)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,level",
Expand Down Expand Up @@ -185,6 +362,69 @@ async def test_crumb_capture_client_error_async(
)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,level",
[
(200, None),
(301, None),
(403, "warning"),
(405, "warning"),
(500, "error"),
],
)
@pytest.mark.parametrize("send_default_pii", [True, False])
async def test_crumb_capture_client_error_async_span_streaming(
sentry_init, capture_events, httpx_mock, status_code, level, send_default_pii
):
httpx_mock.add_response(status_code=status_code)

sentry_init(
integrations=[HttpxIntegration()],
trace_lifecycle="stream",
send_default_pii=send_default_pii,
)

url = "http://example.com/"

with sentry_sdk.traces.start_span(name="segment"):
events = capture_events()

response = await httpx.AsyncClient().get(url)

assert response.status_code == 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

if send_default_pii:
assert crumb["data"] == ApproxDict(
{
"url": url,
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_FRAGMENT: "",
SPANDATA.HTTP_QUERY: "",
SPANDATA.HTTP_STATUS_CODE: status_code,
}
)
else:
assert crumb["data"] == ApproxDict(
{
SPANDATA.HTTP_METHOD: "GET",
SPANDATA.HTTP_STATUS_CODE: status_code,
}
)


def test_outgoing_trace_headers_legacy_sync(sentry_init, httpx_mock):
httpx_mock.add_response()

Expand Down
Loading
Loading