Skip to content
245 changes: 176 additions & 69 deletions sentry_sdk/integrations/httpx.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import sentry_sdk
from sentry_sdk import start_span
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
from sentry_sdk.tracing_utils import (
should_propagate_trace,
add_http_request_source,
should_propagate_trace,
add_sentry_baggage_to_headers,
has_span_streaming_enabled,
)
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
Expand All @@ -20,6 +20,7 @@

if TYPE_CHECKING:
from typing import Any
from sentry_sdk._types import Attributes


try:
Expand Down Expand Up @@ -49,48 +50,102 @@

@ensure_integration_enabled(HttpxIntegration, real_send)
def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
Copy link
Copy Markdown
Contributor

@sentrivana sentrivana Apr 17, 2026

Choose a reason for hiding this comment

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

Please see my comments on the async send since this is essentially the same changeset

client = sentry_sdk.get_client()
is_span_streaming_enabled = has_span_streaming_enabled(client.options)

parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)

if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=request.url
if is_span_streaming_enabled:
with sentry_sdk.traces.start_span(
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
attributes={
"sentry.op": OP.HTTP_CLIENT,
"sentry.origin": HttpxIntegration.origin,
"http.request.method": request.method,
},
) as streamed_span:
attributes: "Attributes" = {}

if parsed_url is not None:
attributes["url.full"] = parsed_url.url
if parsed_url.query:
attributes["url.query"] = parsed_url.query
if parsed_url.fragment:
attributes["url.fragment"] = parsed_url.fragment

if should_propagate_trace(client, str(request.url)):
for (
key,
value,
) in (
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
):
logger.debug(
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
)
)

if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value
if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

rv = real_send(self, request, **kwargs)
try:
rv = real_send(self, request, **kwargs)

span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
attributes["http.response.status_code"] = rv.status_code
finally:
streamed_span.set_attributes(attributes)

with capture_internal_exceptions():
add_http_request_source(span)
# Needs to happen within the context manager as we want to attach the
# final data before the span finishes and is sent for ingesting.
with capture_internal_exceptions():
add_http_request_source(streamed_span)
Comment thread
cursor[bot] marked this conversation as resolved.
else:
with sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)

if should_propagate_trace(client, str(request.url)):
for (
key,
value,
) in (
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
):
logger.debug(
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
)

if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

rv = real_send(self, request, **kwargs)

span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)

with capture_internal_exceptions():
add_http_request_source(span)

return rv

Expand All @@ -103,50 +158,102 @@
async def send(
self: "AsyncClient", request: "Request", **kwargs: "Any"
) -> "Response":
if sentry_sdk.get_client().get_integration(HttpxIntegration) is None:
client = sentry_sdk.get_client()
if client.get_integration(HttpxIntegration) is None:
return await real_send(self, request, **kwargs)

is_span_streaming_enabled = has_span_streaming_enabled(client.options)
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)

if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=request.url
if is_span_streaming_enabled:
with sentry_sdk.traces.start_span(
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
attributes={
"sentry.op": OP.HTTP_CLIENT,
"sentry.origin": HttpxIntegration.origin,
"http.request.method": request.method,
},
) as streamed_span:
attributes: "Attributes" = {}

if parsed_url is not None:
attributes["url.full"] = parsed_url.url
if parsed_url.query:
attributes["url.query"] = parsed_url.query
if parsed_url.fragment:
attributes["url.fragment"] = parsed_url.fragment

if should_propagate_trace(client, str(request.url)):
for (
key,
value,
) in (
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
):
logger.debug(
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
)
)
if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

rv = await real_send(self, request, **kwargs)
if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)
rv = await real_send(self, request, **kwargs)

with capture_internal_exceptions():
add_http_request_source(span)
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
attributes["http.response.status_code"] = rv.status_code

streamed_span.set_attributes(attributes)

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

View check run for this annotation

@sentry/warden / warden: find-bugs

Missing try/finally in async httpx span streaming path causes attributes to be lost on exceptions

In the async httpx client's span streaming path (lines 208-213), if `await real_send()` raises an exception, the `attributes` dictionary containing URL information (url.full, url.query, url.fragment) will never be set on the span. The sync client implementation correctly uses a `try/finally` block (lines 98-109 in the full file) to ensure `set_attributes()` is called even when exceptions occur. This inconsistency causes incomplete span data when async HTTP requests fail.

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

View check run for this annotation

@sentry/warden / warden: code-review

Async span streaming path loses URL attributes if request fails

The async `send` function's span streaming path does not wrap `await real_send()` in a try/finally block, unlike the sync version (lines 98-104). If the HTTP request raises an exception (connection error, timeout, etc.), the `attributes` dict containing `url.full`, `url.query`, and `url.fragment` will never be set on the span via `set_attributes()`. This causes behavioral inconsistency between sync and async httpx clients and loses valuable debugging information in failure scenarios.
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated

# Needs to happen within the context manager as we want to attach the
# final data before the span finishes and is sent for ingesting.
with capture_internal_exceptions():
add_http_request_source(streamed_span)
else:
with sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)

if should_propagate_trace(client, str(request.url)):
for (
key,
value,
) in (
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
):
logger.debug(
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
)
if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

rv = await real_send(self, request, **kwargs)

span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)

with capture_internal_exceptions():
add_http_request_source(span)

return rv

Expand Down
13 changes: 5 additions & 8 deletions sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,9 @@ def __init__(
self._start_timestamp = datetime.now(timezone.utc)
self._timestamp: "Optional[datetime]" = None

try:
# profiling depends on this value and requires that
# it is measured in nanoseconds
self._start_timestamp_monotonic_ns = nanosecond_time()
except AttributeError:
pass
# profiling depends on this value and requires that
# it is measured in nanoseconds
self._start_timestamp_monotonic_ns = nanosecond_time()
Comment thread
ericapisani marked this conversation as resolved.

self._span_id: "Optional[str]" = None

Expand Down Expand Up @@ -385,12 +382,12 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None
)

if self._timestamp is None:
try:
if self._start_timestamp_monotonic_ns is not None:
elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns
self._timestamp = self._start_timestamp + timedelta(
microseconds=elapsed / 1000
)
except AttributeError:
else:
self._timestamp = datetime.now(timezone.utc)

Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
client = sentry_sdk.get_client()
Expand Down
Loading
Loading