Skip to content

Commit e2d0893

Browse files
elonzhahmedetefy
andauthored
feat(integration): Add Httpx Integration (#1119)
* feat(integration): Add Httpx Integration Co-authored-by: Ahmed Etefy <[email protected]>
1 parent 4b4ffc0 commit e2d0893

File tree

5 files changed

+159
-0
lines changed

5 files changed

+159
-0
lines changed

sentry_sdk/integrations/httpx.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from sentry_sdk import Hub
2+
from sentry_sdk.integrations import Integration, DidNotEnable
3+
4+
from sentry_sdk._types import MYPY
5+
6+
if MYPY:
7+
from typing import Any
8+
9+
10+
try:
11+
from httpx import AsyncClient, Client, Request, Response # type: ignore
12+
except ImportError:
13+
raise DidNotEnable("httpx is not installed")
14+
15+
__all__ = ["HttpxIntegration"]
16+
17+
18+
class HttpxIntegration(Integration):
19+
identifier = "httpx"
20+
21+
@staticmethod
22+
def setup_once():
23+
# type: () -> None
24+
"""
25+
httpx has its own transport layer and can be customized when needed,
26+
so patch Client.send and AsyncClient.send to support both synchronous and async interfaces.
27+
"""
28+
_install_httpx_client()
29+
_install_httpx_async_client()
30+
31+
32+
def _install_httpx_client():
33+
# type: () -> None
34+
real_send = Client.send
35+
36+
def send(self, request, **kwargs):
37+
# type: (Client, Request, **Any) -> Response
38+
hub = Hub.current
39+
if hub.get_integration(HttpxIntegration) is None:
40+
return real_send(self, request, **kwargs)
41+
42+
with hub.start_span(
43+
op="http", description="%s %s" % (request.method, request.url)
44+
) as span:
45+
span.set_data("method", request.method)
46+
span.set_data("url", str(request.url))
47+
for key, value in hub.iter_trace_propagation_headers():
48+
request.headers[key] = value
49+
rv = real_send(self, request, **kwargs)
50+
51+
span.set_data("status_code", rv.status_code)
52+
span.set_http_status(rv.status_code)
53+
span.set_data("reason", rv.reason_phrase)
54+
return rv
55+
56+
Client.send = send
57+
58+
59+
def _install_httpx_async_client():
60+
# type: () -> None
61+
real_send = AsyncClient.send
62+
63+
async def send(self, request, **kwargs):
64+
# type: (AsyncClient, Request, **Any) -> Response
65+
hub = Hub.current
66+
if hub.get_integration(HttpxIntegration) is None:
67+
return await real_send(self, request, **kwargs)
68+
69+
with hub.start_span(
70+
op="http", description="%s %s" % (request.method, request.url)
71+
) as span:
72+
span.set_data("method", request.method)
73+
span.set_data("url", str(request.url))
74+
for key, value in hub.iter_trace_propagation_headers():
75+
request.headers[key] = value
76+
rv = await real_send(self, request, **kwargs)
77+
78+
span.set_data("status_code", rv.status_code)
79+
span.set_http_status(rv.status_code)
80+
span.set_data("reason", rv.reason_phrase)
81+
return rv
82+
83+
AsyncClient.send = send

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def get_file_text(file_name):
5353
"pyspark": ["pyspark>=2.4.4"],
5454
"pure_eval": ["pure_eval", "executing", "asttokens"],
5555
"chalice": ["chalice>=1.16.0"],
56+
"httpx": ["httpx>=0.16.0"],
5657
},
5758
classifiers=[
5859
"Development Status :: 5 - Production/Stable",

tests/integrations/httpx/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("httpx")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import asyncio
2+
3+
import httpx
4+
5+
from sentry_sdk import capture_message, start_transaction
6+
from sentry_sdk.integrations.httpx import HttpxIntegration
7+
8+
9+
def test_crumb_capture_and_hint(sentry_init, capture_events):
10+
def before_breadcrumb(crumb, hint):
11+
crumb["data"]["extra"] = "foo"
12+
return crumb
13+
14+
sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb)
15+
clients = (httpx.Client(), httpx.AsyncClient())
16+
for i, c in enumerate(clients):
17+
with start_transaction():
18+
events = capture_events()
19+
20+
url = "https://httpbin.org/status/200"
21+
if not asyncio.iscoroutinefunction(c.get):
22+
response = c.get(url)
23+
else:
24+
response = asyncio.get_event_loop().run_until_complete(c.get(url))
25+
26+
assert response.status_code == 200
27+
capture_message("Testing!")
28+
29+
(event,) = events
30+
# send request twice so we need get breadcrumb by index
31+
crumb = event["breadcrumbs"]["values"][i]
32+
assert crumb["type"] == "http"
33+
assert crumb["category"] == "httplib"
34+
assert crumb["data"] == {
35+
"url": url,
36+
"method": "GET",
37+
"status_code": 200,
38+
"reason": "OK",
39+
"extra": "foo",
40+
}
41+
42+
43+
def test_outgoing_trace_headers(sentry_init):
44+
sentry_init(traces_sample_rate=1.0, integrations=[HttpxIntegration()])
45+
clients = (httpx.Client(), httpx.AsyncClient())
46+
for i, c in enumerate(clients):
47+
with start_transaction(
48+
name="/interactions/other-dogs/new-dog",
49+
op="greeting.sniff",
50+
# make trace_id difference between transactions
51+
trace_id=f"012345678901234567890123456789{i}",
52+
) as transaction:
53+
url = "https://httpbin.org/status/200"
54+
if not asyncio.iscoroutinefunction(c.get):
55+
response = c.get(url)
56+
else:
57+
response = asyncio.get_event_loop().run_until_complete(c.get(url))
58+
59+
request_span = transaction._span_recorder.spans[-1]
60+
assert response.request.headers[
61+
"sentry-trace"
62+
] == "{trace_id}-{parent_span_id}-{sampled}".format(
63+
trace_id=transaction.trace_id,
64+
parent_span_id=request_span.span_id,
65+
sampled=1,
66+
)

tox.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ envlist =
8383

8484
{py2.7,py3.6,py3.7,py3.8}-boto3-{1.9,1.10,1.11,1.12,1.13,1.14,1.15,1.16}
8585

86+
{py3.6,py3.7,py3.8,py3.9}-httpx-{0.16,0.17}
87+
8688
[testenv]
8789
deps =
8890
# if you change test-requirements.txt and your change is not being reflected
@@ -235,6 +237,9 @@ deps =
235237
boto3-1.15: boto3>=1.15,<1.16
236238
boto3-1.16: boto3>=1.16,<1.17
237239

240+
httpx-0.16: httpx>=0.16,<0.17
241+
httpx-0.17: httpx>=0.17,<0.18
242+
238243
setenv =
239244
PYTHONDONTWRITEBYTECODE=1
240245
TESTPATH=tests
@@ -260,6 +265,7 @@ setenv =
260265
pure_eval: TESTPATH=tests/integrations/pure_eval
261266
chalice: TESTPATH=tests/integrations/chalice
262267
boto3: TESTPATH=tests/integrations/boto3
268+
httpx: TESTPATH=tests/integrations/httpx
263269

264270
COVERAGE_FILE=.coverage-{envname}
265271
passenv =

0 commit comments

Comments
 (0)