Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
241 changes: 173 additions & 68 deletions sentry_sdk/integrations/httpx.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import sentry_sdk
from sentry_sdk import start_span
from sentry_sdk import start_span as legacy_start_span
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.traces import start_span
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
from sentry_sdk.tracing_utils import (
add_http_request_source_for_streamed_span,
should_propagate_trace,
add_http_request_source,
add_sentry_baggage_to_headers,
has_span_streaming_enabled,
)
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
Expand All @@ -20,6 +23,7 @@

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


try:
Expand Down Expand Up @@ -49,48 +53,99 @@

@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 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,
SPANDATA.HTTP_METHOD: request.method,
},
) as segment:
attributes: "Attributes" = {}

if parsed_url is not None:
attributes["url"] = parsed_url.url
attributes[SPANDATA.HTTP_QUERY] = parsed_url.query
attributes[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
if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

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

span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)
segment.status = "error" if rv.status_code >= 400 else "ok"
attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code

with capture_internal_exceptions():
add_http_request_source(span)
segment.set_attributes(attributes)

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

View check run for this annotation

@sentry/warden / warden: code-review

Missing 'reason' attribute in span streaming path causes data loss

The new span streaming code path (lines 99-109) does not include the HTTP reason phrase (`rv.reason_phrase`) in the span attributes. The legacy path at line 145 sets `span.set_data("reason", rv.reason_phrase)`, but the streaming path only sets status and `HTTP_STATUS_CODE`. This means users migrating to span streaming will lose the reason phrase data from their HTTP client spans.
Comment thread
ericapisani 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_for_streamed_span(segment)
else:
with legacy_start_span(
Comment thread
ericapisani marked this conversation as resolved.
Outdated
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,100 @@
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 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,
SPANDATA.HTTP_METHOD: request.method,
Comment thread
ericapisani marked this conversation as resolved.
Outdated
},
) as segment:
Comment thread
ericapisani marked this conversation as resolved.
Outdated
attributes: "Attributes" = {}

if parsed_url is not None:
attributes["url"] = parsed_url.url
Comment thread
ericapisani marked this conversation as resolved.
Outdated
attributes[SPANDATA.HTTP_QUERY] = parsed_url.query
Comment thread
ericapisani marked this conversation as resolved.
Outdated
attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment
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.

Same case as https://github.com/getsentry/sentry-python/pull/6084/changes#r3098336387 -- there's http.fragment and url.fragment, one contains the leading # while the other one doesn't, we might want url.fragment here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've asked about http vs url in the project channel just in case others have thoughts, but have optimistically picked url in 25a953e


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)
segment.status = "error" if rv.status_code >= 400 else "ok"
attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code
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.

https://getsentry.github.io/sentry-conventions/attributes/http/#http-status_code

Suggested change
attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code
attributes["http.response.status_code"] = rv.status_code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 25a953e


segment.set_attributes(attributes)

# 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_for_streamed_span(segment)
else:
with legacy_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
14 changes: 11 additions & 3 deletions sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def __init__(
# it is measured in nanoseconds
self._start_timestamp_monotonic_ns = nanosecond_time()
except AttributeError:
pass
self._start_timestamp_monotonic_ns = None

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

Expand Down Expand Up @@ -385,12 +385,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)

client = sentry_sdk.get_client()
Expand Down Expand Up @@ -467,6 +467,10 @@ def sampled(self) -> "Optional[bool]":
def start_timestamp(self) -> "Optional[datetime]":
return self._start_timestamp

@property
def start_timestamp_monotonic_ns(self) -> "Optional[int]":
return self._start_timestamp_monotonic_ns
Comment thread
ericapisani marked this conversation as resolved.
Comment on lines +467 to +469
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.

Let's not expose new properties on the span -- the idea is to keep the API surface minimal. We can still use the attribute directly in our code if we need to, but I think we don't (see other comments).


@property
def timestamp(self) -> "Optional[datetime]":
return self._timestamp
Expand Down Expand Up @@ -681,6 +685,10 @@ def sampled(self) -> "Optional[bool]":
def start_timestamp(self) -> "Optional[datetime]":
return None

@property
def start_timestamp_monotonic_ns(self) -> "Optional[int]":
return None

@property
def timestamp(self) -> "Optional[datetime]":
return None
Expand Down
Loading
Loading