Skip to content

Commit 66d2c00

Browse files
committed
Instrument httpx >= 0.20
Fixes aws#248
1 parent 5d99316 commit 66d2c00

File tree

7 files changed

+473
-0
lines changed

7 files changed

+473
-0
lines changed

aws_xray_sdk/core/patcher.py

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
'psycopg2',
2626
'pg8000',
2727
'sqlalchemy_core',
28+
'httpx',
2829
)
2930

3031
NO_DOUBLE_PATCH = (
@@ -39,6 +40,7 @@
3940
'psycopg2',
4041
'pg8000',
4142
'sqlalchemy_core',
43+
'httpx',
4244
)
4345

4446
_PATCHED_MODULES = set()

aws_xray_sdk/ext/httpx/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .patch import patch
2+
3+
__all__ = ['patch']

aws_xray_sdk/ext/httpx/patch.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import httpx
2+
3+
from aws_xray_sdk.core import xray_recorder
4+
from aws_xray_sdk.core.models import http
5+
from aws_xray_sdk.ext.util import UNKNOWN_HOSTNAME, inject_trace_header
6+
7+
8+
def patch():
9+
httpx.Client = _InstrumentedClient
10+
httpx.AsyncClient = _InstrumentedAsyncClient
11+
httpx._api.Client = _InstrumentedClient
12+
13+
14+
class _InstrumentedClient(httpx.Client):
15+
16+
_request_hook = None
17+
_response_hook = None
18+
19+
def __init__(self, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
21+
22+
self._original_transport = self._transport
23+
self._transport = SyncInstrumentedTransport(self._transport)
24+
25+
26+
class _InstrumentedAsyncClient(httpx.AsyncClient):
27+
def __init__(self, *args, **kwargs):
28+
super().__init__(*args, **kwargs)
29+
30+
self._original_transport = self._transport
31+
self._transport = AsyncInstrumentedTransport(self._transport)
32+
33+
34+
class SyncInstrumentedTransport(httpx.BaseTransport):
35+
def __init__(self, transport: httpx.BaseTransport):
36+
self._wrapped_transport = transport
37+
38+
def handle_request(self, request: httpx.Request) -> httpx.Response:
39+
def httpx_processor(return_value, exception, subsegment, stack, **kwargs):
40+
subsegment.put_http_meta(http.METHOD, request.method)
41+
subsegment.put_http_meta(
42+
http.URL,
43+
str(request.url.copy_with(password=None, query=None, fragment=None)),
44+
)
45+
46+
if return_value is not None:
47+
subsegment.put_http_meta(http.STATUS, return_value.status_code)
48+
elif exception:
49+
subsegment.add_exception(exception, stack)
50+
51+
inject_trace_header(request.headers, xray_recorder.current_subsegment())
52+
return xray_recorder.record_subsegment(
53+
wrapped=self._wrapped_transport.handle_request,
54+
instance=self._wrapped_transport,
55+
args=(request,),
56+
kwargs={},
57+
name=request.url.host or UNKNOWN_HOSTNAME,
58+
namespace="remote",
59+
meta_processor=httpx_processor,
60+
)
61+
62+
63+
class AsyncInstrumentedTransport(httpx.AsyncBaseTransport):
64+
def __init__(self, transport: httpx.AsyncBaseTransport):
65+
self._wrapped_transport = transport
66+
67+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
68+
def httpx_processor(return_value, exception, subsegment, stack, **kwargs):
69+
subsegment.put_http_meta(http.METHOD, request.method)
70+
subsegment.put_http_meta(
71+
http.URL,
72+
str(request.url.copy_with(password=None, query=None, fragment=None)),
73+
)
74+
75+
if return_value is not None:
76+
subsegment.put_http_meta(http.STATUS, return_value.status_code)
77+
elif exception:
78+
subsegment.add_exception(exception, stack)
79+
80+
inject_trace_header(request.headers, xray_recorder.current_subsegment())
81+
return await xray_recorder.record_subsegment_async(
82+
wrapped=self._wrapped_transport.handle_async_request,
83+
instance=self._wrapped_transport,
84+
args=(request,),
85+
kwargs={},
86+
name=request.url.host or UNKNOWN_HOSTNAME,
87+
namespace="remote",
88+
meta_processor=httpx_processor,
89+
)

tests/ext/httpx/__init__.py

Whitespace-only changes.

tests/ext/httpx/test_httpx.py

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import pytest
2+
3+
import httpx
4+
from aws_xray_sdk.core import patch
5+
from aws_xray_sdk.core import xray_recorder
6+
from aws_xray_sdk.core.context import Context
7+
from aws_xray_sdk.ext.util import strip_url, get_hostname
8+
9+
10+
patch(("httpx",))
11+
12+
# httpbin.org is created by the same author of requests to make testing http easy.
13+
BASE_URL = "httpbin.org"
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def construct_ctx():
18+
"""
19+
Clean up context storage on each test run and begin a segment
20+
so that later subsegment can be attached. After each test run
21+
it cleans up context storage again.
22+
"""
23+
xray_recorder.configure(service="test", sampling=False, context=Context())
24+
xray_recorder.clear_trace_entities()
25+
xray_recorder.begin_segment("name")
26+
yield
27+
xray_recorder.clear_trace_entities()
28+
29+
30+
@pytest.mark.parametrize("use_client", (True, False))
31+
def test_ok(use_client):
32+
status_code = 200
33+
url = "http://{}/status/{}?foo=bar".format(BASE_URL, status_code)
34+
if use_client:
35+
with httpx.Client() as client:
36+
client.get(url)
37+
else:
38+
httpx.get(url)
39+
subsegment = xray_recorder.current_segment().subsegments[0]
40+
assert get_hostname(url) == BASE_URL
41+
assert subsegment.name == get_hostname(url)
42+
43+
http_meta = subsegment.http
44+
assert http_meta["request"]["url"] == strip_url(url)
45+
assert http_meta["request"]["method"].upper() == "GET"
46+
assert http_meta["response"]["status"] == status_code
47+
48+
49+
@pytest.mark.parametrize("use_client", (True, False))
50+
def test_error(use_client):
51+
status_code = 400
52+
url = "http://{}/status/{}".format(BASE_URL, status_code)
53+
if use_client:
54+
with httpx.Client() as client:
55+
client.post(url)
56+
else:
57+
httpx.post(url)
58+
subsegment = xray_recorder.current_segment().subsegments[0]
59+
assert subsegment.name == get_hostname(url)
60+
assert subsegment.error
61+
62+
http_meta = subsegment.http
63+
assert http_meta["request"]["url"] == strip_url(url)
64+
assert http_meta["request"]["method"].upper() == "POST"
65+
assert http_meta["response"]["status"] == status_code
66+
67+
68+
@pytest.mark.parametrize("use_client", (True, False))
69+
def test_throttle(use_client):
70+
status_code = 429
71+
url = "http://{}/status/{}".format(BASE_URL, status_code)
72+
if use_client:
73+
with httpx.Client() as client:
74+
client.head(url)
75+
else:
76+
httpx.head(url)
77+
subsegment = xray_recorder.current_segment().subsegments[0]
78+
assert subsegment.name == get_hostname(url)
79+
assert subsegment.error
80+
assert subsegment.throttle
81+
82+
http_meta = subsegment.http
83+
assert http_meta["request"]["url"] == strip_url(url)
84+
assert http_meta["request"]["method"].upper() == "HEAD"
85+
assert http_meta["response"]["status"] == status_code
86+
87+
88+
@pytest.mark.parametrize("use_client", (True, False))
89+
def test_fault(use_client):
90+
status_code = 500
91+
url = "http://{}/status/{}".format(BASE_URL, status_code)
92+
if use_client:
93+
with httpx.Client() as client:
94+
client.put(url)
95+
else:
96+
httpx.put(url)
97+
subsegment = xray_recorder.current_segment().subsegments[0]
98+
assert subsegment.name == get_hostname(url)
99+
assert subsegment.fault
100+
101+
http_meta = subsegment.http
102+
assert http_meta["request"]["url"] == strip_url(url)
103+
assert http_meta["request"]["method"].upper() == "PUT"
104+
assert http_meta["response"]["status"] == status_code
105+
106+
107+
@pytest.mark.parametrize("use_client", (True, False))
108+
def test_nonexistent_domain(use_client):
109+
with pytest.raises(httpx.ConnectError):
110+
if use_client:
111+
with httpx.Client() as client:
112+
client.get("http://doesnt.exist")
113+
else:
114+
httpx.get("http://doesnt.exist")
115+
116+
subsegment = xray_recorder.current_segment().subsegments[0]
117+
assert subsegment.fault
118+
119+
exception = subsegment.cause["exceptions"][0]
120+
assert exception.type == "ConnectError"
121+
122+
123+
@pytest.mark.parametrize("use_client", (True, False))
124+
def test_invalid_url(use_client):
125+
url = "KLSDFJKLSDFJKLSDJF"
126+
with pytest.raises(httpx.UnsupportedProtocol):
127+
if use_client:
128+
with httpx.Client() as client:
129+
client.get(url)
130+
else:
131+
httpx.get(url)
132+
133+
subsegment = xray_recorder.current_segment().subsegments[0]
134+
assert subsegment.name == get_hostname(url)
135+
assert subsegment.fault
136+
137+
http_meta = subsegment.http
138+
assert http_meta["request"]["url"] == "/{}".format(strip_url(url))
139+
140+
exception = subsegment.cause["exceptions"][0]
141+
assert exception.type == "UnsupportedProtocol"
142+
143+
144+
@pytest.mark.parametrize("use_client", (True, False))
145+
def test_name_uses_hostname(use_client):
146+
if use_client:
147+
client = httpx.Client()
148+
else:
149+
client = httpx
150+
151+
try:
152+
url1 = "http://{}/fakepath/stuff/koo/lai/ahh".format(BASE_URL)
153+
client.get(url1)
154+
subsegment = xray_recorder.current_segment().subsegments[-1]
155+
assert subsegment.name == BASE_URL
156+
http_meta1 = subsegment.http
157+
assert http_meta1["request"]["url"] == strip_url(url1)
158+
assert http_meta1["request"]["method"].upper() == "GET"
159+
160+
url2 = "http://{}/".format(BASE_URL)
161+
client.get(url2, params={"some": "payload", "not": "toBeIncluded"})
162+
subsegment = xray_recorder.current_segment().subsegments[-1]
163+
assert subsegment.name == BASE_URL
164+
http_meta2 = subsegment.http
165+
assert http_meta2["request"]["url"] == strip_url(url2)
166+
assert http_meta2["request"]["method"].upper() == "GET"
167+
168+
url3 = "http://subdomain.{}/fakepath/stuff/koo/lai/ahh".format(BASE_URL)
169+
try:
170+
client.get(url3)
171+
except httpx.ConnectError:
172+
pass
173+
subsegment = xray_recorder.current_segment().subsegments[-1]
174+
assert subsegment.name == "subdomain." + BASE_URL
175+
http_meta3 = subsegment.http
176+
assert http_meta3["request"]["url"] == strip_url(url3)
177+
assert http_meta3["request"]["method"].upper() == "GET"
178+
finally:
179+
if use_client:
180+
client.close()
181+
182+
183+
@pytest.mark.parametrize("use_client", (True, False))
184+
def test_strip_http_url(use_client):
185+
status_code = 200
186+
url = "http://{}/get?foo=bar".format(BASE_URL)
187+
if use_client:
188+
with httpx.Client() as client:
189+
client.get(url)
190+
else:
191+
httpx.get(url)
192+
subsegment = xray_recorder.current_segment().subsegments[0]
193+
assert subsegment.name == get_hostname(url)
194+
195+
http_meta = subsegment.http
196+
assert http_meta["request"]["url"] == strip_url(url)
197+
assert http_meta["request"]["method"].upper() == "GET"
198+
assert http_meta["response"]["status"] == status_code

0 commit comments

Comments
 (0)