Skip to content

Commit 25272c9

Browse files
authored
feat: Support httpx2 (#6463)
### Description Support [httpx2](https://github.com/pydantic/httpx2). Copy our httpx integration and tests and use that as the basis for the new integration, so that if the new fork diverges, we can only edit the dedicated integration. Changes compared to `HttpxIntegration`: - Renamed all httpx -> httpx2 (duh) - Needed a replacement for `pytest-httpx`. Picked `httpx2-pytest` since it's drop-in. Since it's a new project, pinned it to the current version for additional safety. - The tests needed adapting to be 3.14 compatible (httpx isn't tested on 3.14): `asyncio.get_event_loop().run_until_complete` -> `asyncio.run` Docs: getsentry/sentry-docs#17940 #### Issues - Closes #6448 - Closes https://linear.app/getsentry/issue/PY-2502/add-support-for-httpx2 #### Reminders - Please add tests to validate your changes, and lint your code using `uv run ruff`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
1 parent ba5fe8c commit 25272c9

14 files changed

Lines changed: 1521 additions & 0 deletions

File tree

.github/workflows/test-integrations-network.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ jobs:
5959
run: |
6060
set -x # print commands that are executed
6161
./scripts/runtox.sh "py${{ matrix.python-version }}-httpx"
62+
- name: Test httpx2
63+
run: |
64+
set -x # print commands that are executed
65+
./scripts/runtox.sh "py${{ matrix.python-version }}-httpx2"
6266
- name: Test pyreqwest
6367
run: |
6468
set -x # print commands that are executed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ typing = [
6464
"typer",
6565
"strawberry-graphql",
6666
"httpx",
67+
"httpx2",
6768
"botocore-stubs",
6869
"werkzeug",
6970
]

scripts/populate_tox/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@
202202
">=0.28": ">=3.9",
203203
},
204204
},
205+
"httpx2": {
206+
"package": "httpx2",
207+
"deps": {
208+
"*": ["anyio>=3,<5", "httpx2-pytest==1.0.1"],
209+
},
210+
"python": ">=3.10",
211+
},
205212
"huey": {
206213
"package": "huey",
207214
"num_versions": 2,

scripts/populate_tox/package_dependencies.jsonl

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/populate_tox/releases.jsonl

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"Network": [
125125
"grpc",
126126
"httpx",
127+
"httpx2",
127128
"pyreqwest",
128129
"requests",
129130
],

sentry_sdk/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def iter_default_integrations(
8484
"sentry_sdk.integrations.google_genai.GoogleGenAIIntegration",
8585
"sentry_sdk.integrations.graphene.GrapheneIntegration",
8686
"sentry_sdk.integrations.httpx.HttpxIntegration",
87+
"sentry_sdk.integrations.httpx2.Httpx2Integration",
8788
"sentry_sdk.integrations.huey.HueyIntegration",
8889
"sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration",
8990
"sentry_sdk.integrations.langchain.LangchainIntegration",
@@ -138,6 +139,7 @@ def iter_default_integrations(
138139
"google_genai": (1, 29, 0), # google-genai
139140
"grpc": (1, 32, 0), # grpcio
140141
"httpx": (0, 16, 0),
142+
"httpx2": (2, 0, 0),
141143
"huggingface_hub": (0, 24, 7),
142144
"langchain": (0, 1, 0),
143145
"langgraph": (0, 6, 6),

sentry_sdk/integrations/httpx2.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
from typing import TYPE_CHECKING
2+
3+
import sentry_sdk
4+
from sentry_sdk.consts import OP, SPANDATA
5+
from sentry_sdk.integrations import DidNotEnable, Integration
6+
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
7+
from sentry_sdk.tracing_utils import (
8+
add_http_request_source,
9+
add_sentry_baggage_to_headers,
10+
has_span_streaming_enabled,
11+
should_propagate_trace,
12+
)
13+
from sentry_sdk.utils import (
14+
SENSITIVE_DATA_SUBSTITUTE,
15+
capture_internal_exceptions,
16+
ensure_integration_enabled,
17+
logger,
18+
parse_url,
19+
)
20+
21+
if TYPE_CHECKING:
22+
from typing import Any
23+
24+
from sentry_sdk._types import Attributes
25+
26+
27+
try:
28+
from httpx2 import AsyncClient, Client, Request, Response
29+
except ImportError:
30+
raise DidNotEnable("httpx2 is not installed")
31+
32+
__all__ = ["Httpx2Integration"]
33+
34+
35+
class Httpx2Integration(Integration):
36+
identifier = "httpx2"
37+
origin = f"auto.http.{identifier}"
38+
39+
@staticmethod
40+
def setup_once() -> None:
41+
"""
42+
httpx2 has its own transport layer and can be customized when needed,
43+
so patch Client.send and AsyncClient.send to support both synchronous and async interfaces.
44+
"""
45+
_install_httpx2_client()
46+
_install_httpx2_async_client()
47+
48+
49+
def _install_httpx2_client() -> None:
50+
real_send = Client.send
51+
52+
@ensure_integration_enabled(Httpx2Integration, real_send)
53+
def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
54+
client = sentry_sdk.get_client()
55+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
56+
57+
parsed_url = None
58+
with capture_internal_exceptions():
59+
parsed_url = parse_url(str(request.url), sanitize=False)
60+
61+
if is_span_streaming_enabled:
62+
with sentry_sdk.traces.start_span(
63+
name="%s %s"
64+
% (
65+
request.method,
66+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
67+
),
68+
attributes={
69+
"sentry.op": OP.HTTP_CLIENT,
70+
"sentry.origin": Httpx2Integration.origin,
71+
"http.request.method": request.method,
72+
},
73+
) as streamed_span:
74+
attributes: "Attributes" = {}
75+
76+
if parsed_url is not None:
77+
attributes["url.full"] = parsed_url.url
78+
if parsed_url.query:
79+
attributes["url.query"] = parsed_url.query
80+
if parsed_url.fragment:
81+
attributes["url.fragment"] = parsed_url.fragment
82+
83+
if should_propagate_trace(client, str(request.url)):
84+
for (
85+
key,
86+
value,
87+
) in (
88+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
89+
):
90+
logger.debug(
91+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
92+
)
93+
94+
if key == BAGGAGE_HEADER_NAME:
95+
add_sentry_baggage_to_headers(request.headers, value)
96+
else:
97+
request.headers[key] = value
98+
99+
try:
100+
rv = real_send(self, request, **kwargs)
101+
102+
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
103+
attributes["http.response.status_code"] = rv.status_code
104+
finally:
105+
streamed_span.set_attributes(attributes)
106+
107+
# Needs to happen within the context manager as we want to attach the
108+
# final data before the span finishes and is sent for ingesting.
109+
with capture_internal_exceptions():
110+
add_http_request_source(streamed_span)
111+
else:
112+
with sentry_sdk.start_span(
113+
op=OP.HTTP_CLIENT,
114+
name="%s %s"
115+
% (
116+
request.method,
117+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
118+
),
119+
origin=Httpx2Integration.origin,
120+
) as span:
121+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
122+
if parsed_url is not None:
123+
span.set_data("url", parsed_url.url)
124+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
125+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
126+
127+
if should_propagate_trace(client, str(request.url)):
128+
for (
129+
key,
130+
value,
131+
) in (
132+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
133+
):
134+
logger.debug(
135+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
136+
)
137+
138+
if key == BAGGAGE_HEADER_NAME:
139+
add_sentry_baggage_to_headers(request.headers, value)
140+
else:
141+
request.headers[key] = value
142+
143+
rv = real_send(self, request, **kwargs)
144+
145+
span.set_http_status(rv.status_code)
146+
span.set_data("reason", rv.reason_phrase)
147+
148+
with capture_internal_exceptions():
149+
add_http_request_source(span)
150+
151+
return rv
152+
153+
Client.send = send # type: ignore
154+
155+
156+
def _install_httpx2_async_client() -> None:
157+
real_send = AsyncClient.send
158+
159+
async def send(
160+
self: "AsyncClient", request: "Request", **kwargs: "Any"
161+
) -> "Response":
162+
client = sentry_sdk.get_client()
163+
if client.get_integration(Httpx2Integration) is None:
164+
return await real_send(self, request, **kwargs)
165+
166+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
167+
parsed_url = None
168+
with capture_internal_exceptions():
169+
parsed_url = parse_url(str(request.url), sanitize=False)
170+
171+
if is_span_streaming_enabled:
172+
with sentry_sdk.traces.start_span(
173+
name="%s %s"
174+
% (
175+
request.method,
176+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
177+
),
178+
attributes={
179+
"sentry.op": OP.HTTP_CLIENT,
180+
"sentry.origin": Httpx2Integration.origin,
181+
"http.request.method": request.method,
182+
},
183+
) as streamed_span:
184+
attributes: "Attributes" = {}
185+
186+
if parsed_url is not None:
187+
attributes["url.full"] = parsed_url.url
188+
if parsed_url.query:
189+
attributes["url.query"] = parsed_url.query
190+
if parsed_url.fragment:
191+
attributes["url.fragment"] = parsed_url.fragment
192+
193+
if should_propagate_trace(client, str(request.url)):
194+
for (
195+
key,
196+
value,
197+
) in (
198+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
199+
):
200+
logger.debug(
201+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
202+
)
203+
204+
if key == BAGGAGE_HEADER_NAME:
205+
add_sentry_baggage_to_headers(request.headers, value)
206+
else:
207+
request.headers[key] = value
208+
209+
try:
210+
rv = await real_send(self, request, **kwargs)
211+
212+
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
213+
attributes["http.response.status_code"] = rv.status_code
214+
finally:
215+
streamed_span.set_attributes(attributes)
216+
217+
# Needs to happen within the context manager as we want to attach the
218+
# final data before the span finishes and is sent for ingesting.
219+
with capture_internal_exceptions():
220+
add_http_request_source(streamed_span)
221+
else:
222+
with sentry_sdk.start_span(
223+
op=OP.HTTP_CLIENT,
224+
name="%s %s"
225+
% (
226+
request.method,
227+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
228+
),
229+
origin=Httpx2Integration.origin,
230+
) as span:
231+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
232+
if parsed_url is not None:
233+
span.set_data("url", parsed_url.url)
234+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
235+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
236+
237+
if should_propagate_trace(client, str(request.url)):
238+
for (
239+
key,
240+
value,
241+
) in (
242+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
243+
):
244+
logger.debug(
245+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
246+
)
247+
if key == BAGGAGE_HEADER_NAME:
248+
add_sentry_baggage_to_headers(request.headers, value)
249+
else:
250+
request.headers[key] = value
251+
252+
rv = await real_send(self, request, **kwargs)
253+
254+
span.set_http_status(rv.status_code)
255+
span.set_data("reason", rv.reason_phrase)
256+
257+
with capture_internal_exceptions():
258+
add_http_request_source(span)
259+
260+
return rv
261+
262+
AsyncClient.send = send # type: ignore
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import os
2+
import sys
3+
4+
import pytest
5+
6+
pytest.importorskip("httpx2")
7+
8+
# Load `httpx2_helpers` into the module search path to test request source path names relative to module. See
9+
# `test_request_source_with_module_in_search_path`
10+
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))

tests/integrations/httpx2/httpx2_helpers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)