Skip to content

Commit 80dbd1d

Browse files
michael-ksrprash
andauthored
Instrument httpx >= 0.20 (#357)
* Instrument httpx >= 0.20 Fixes #248 * [ext.httpx] Call `inject_trace_header` with correct subsegment Co-authored-by: Prashant Srivastava <[email protected]>
1 parent cd0fed7 commit 80dbd1d

File tree

7 files changed

+491
-0
lines changed

7 files changed

+491
-0
lines changed

aws_xray_sdk/core/patcher.py

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
'psycopg2',
2727
'pg8000',
2828
'sqlalchemy_core',
29+
'httpx',
2930
)
3031

3132
NO_DOUBLE_PATCH = (
@@ -40,6 +41,7 @@
4041
'psycopg2',
4142
'pg8000',
4243
'sqlalchemy_core',
44+
'httpx',
4345
)
4446

4547
_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

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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 inject_trace_header, get_hostname
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+
def __init__(self, *args, **kwargs):
16+
super().__init__(*args, **kwargs)
17+
18+
self._original_transport = self._transport
19+
self._transport = SyncInstrumentedTransport(self._transport)
20+
21+
22+
class _InstrumentedAsyncClient(httpx.AsyncClient):
23+
def __init__(self, *args, **kwargs):
24+
super().__init__(*args, **kwargs)
25+
26+
self._original_transport = self._transport
27+
self._transport = AsyncInstrumentedTransport(self._transport)
28+
29+
30+
class SyncInstrumentedTransport(httpx.BaseTransport):
31+
def __init__(self, transport: httpx.BaseTransport):
32+
self._wrapped_transport = transport
33+
34+
def handle_request(self, request: httpx.Request) -> httpx.Response:
35+
with xray_recorder.in_subsegment(
36+
get_hostname(str(request.url)), namespace="remote"
37+
) as subsegment:
38+
if subsegment is not None:
39+
subsegment.put_http_meta(http.METHOD, request.method)
40+
subsegment.put_http_meta(
41+
http.URL,
42+
str(request.url.copy_with(password=None, query=None, fragment=None)),
43+
)
44+
inject_trace_header(request.headers, subsegment)
45+
46+
response = self._wrapped_transport.handle_request(request)
47+
if subsegment is not None:
48+
subsegment.put_http_meta(http.STATUS, response.status_code)
49+
return response
50+
51+
52+
class AsyncInstrumentedTransport(httpx.AsyncBaseTransport):
53+
def __init__(self, transport: httpx.AsyncBaseTransport):
54+
self._wrapped_transport = transport
55+
56+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
57+
async with xray_recorder.in_subsegment_async(
58+
get_hostname(str(request.url)), namespace="remote"
59+
) as subsegment:
60+
if subsegment is not None:
61+
subsegment.put_http_meta(http.METHOD, request.method)
62+
subsegment.put_http_meta(
63+
http.URL,
64+
str(request.url.copy_with(password=None, query=None, fragment=None)),
65+
)
66+
inject_trace_header(request.headers, subsegment)
67+
68+
response = await self._wrapped_transport.handle_async_request(request)
69+
if subsegment is not None:
70+
subsegment.put_http_meta(http.STATUS, response.status_code)
71+
return response

tests/ext/httpx/__init__.py

Whitespace-only changes.

tests/ext/httpx/test_httpx.py

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
response = client.get(url)
37+
else:
38+
response = httpx.get(url)
39+
assert "x-amzn-trace-id" in response._request.headers
40+
41+
subsegment = xray_recorder.current_segment().subsegments[0]
42+
assert get_hostname(url) == BASE_URL
43+
assert subsegment.namespace == "remote"
44+
assert subsegment.name == get_hostname(url)
45+
46+
http_meta = subsegment.http
47+
assert http_meta["request"]["url"] == strip_url(url)
48+
assert http_meta["request"]["method"].upper() == "GET"
49+
assert http_meta["response"]["status"] == status_code
50+
51+
52+
@pytest.mark.parametrize("use_client", (True, False))
53+
def test_error(use_client):
54+
status_code = 400
55+
url = "http://{}/status/{}".format(BASE_URL, status_code)
56+
if use_client:
57+
with httpx.Client() as client:
58+
response = client.post(url)
59+
else:
60+
response = httpx.post(url)
61+
assert "x-amzn-trace-id" in response._request.headers
62+
63+
subsegment = xray_recorder.current_segment().subsegments[0]
64+
assert subsegment.namespace == "remote"
65+
assert subsegment.name == get_hostname(url)
66+
assert subsegment.error
67+
68+
http_meta = subsegment.http
69+
assert http_meta["request"]["url"] == strip_url(url)
70+
assert http_meta["request"]["method"].upper() == "POST"
71+
assert http_meta["response"]["status"] == status_code
72+
73+
74+
@pytest.mark.parametrize("use_client", (True, False))
75+
def test_throttle(use_client):
76+
status_code = 429
77+
url = "http://{}/status/{}".format(BASE_URL, status_code)
78+
if use_client:
79+
with httpx.Client() as client:
80+
response = client.head(url)
81+
else:
82+
response = httpx.head(url)
83+
assert "x-amzn-trace-id" in response._request.headers
84+
85+
subsegment = xray_recorder.current_segment().subsegments[0]
86+
assert subsegment.namespace == "remote"
87+
assert subsegment.name == get_hostname(url)
88+
assert subsegment.error
89+
assert subsegment.throttle
90+
91+
http_meta = subsegment.http
92+
assert http_meta["request"]["url"] == strip_url(url)
93+
assert http_meta["request"]["method"].upper() == "HEAD"
94+
assert http_meta["response"]["status"] == status_code
95+
96+
97+
@pytest.mark.parametrize("use_client", (True, False))
98+
def test_fault(use_client):
99+
status_code = 500
100+
url = "http://{}/status/{}".format(BASE_URL, status_code)
101+
if use_client:
102+
with httpx.Client() as client:
103+
response = client.put(url)
104+
else:
105+
response = httpx.put(url)
106+
assert "x-amzn-trace-id" in response._request.headers
107+
108+
subsegment = xray_recorder.current_segment().subsegments[0]
109+
assert subsegment.namespace == "remote"
110+
assert subsegment.name == get_hostname(url)
111+
assert subsegment.fault
112+
113+
http_meta = subsegment.http
114+
assert http_meta["request"]["url"] == strip_url(url)
115+
assert http_meta["request"]["method"].upper() == "PUT"
116+
assert http_meta["response"]["status"] == status_code
117+
118+
119+
@pytest.mark.parametrize("use_client", (True, False))
120+
def test_nonexistent_domain(use_client):
121+
with pytest.raises(httpx.ConnectError):
122+
if use_client:
123+
with httpx.Client() as client:
124+
client.get("http://doesnt.exist")
125+
else:
126+
httpx.get("http://doesnt.exist")
127+
128+
subsegment = xray_recorder.current_segment().subsegments[0]
129+
assert subsegment.namespace == "remote"
130+
assert subsegment.fault
131+
132+
exception = subsegment.cause["exceptions"][0]
133+
assert exception.type == "ConnectError"
134+
135+
136+
@pytest.mark.parametrize("use_client", (True, False))
137+
def test_invalid_url(use_client):
138+
url = "KLSDFJKLSDFJKLSDJF"
139+
with pytest.raises(httpx.UnsupportedProtocol):
140+
if use_client:
141+
with httpx.Client() as client:
142+
client.get(url)
143+
else:
144+
httpx.get(url)
145+
146+
subsegment = xray_recorder.current_segment().subsegments[0]
147+
assert subsegment.namespace == "remote"
148+
assert subsegment.name == get_hostname(url)
149+
assert subsegment.fault
150+
151+
http_meta = subsegment.http
152+
assert http_meta["request"]["url"] == "/{}".format(strip_url(url))
153+
154+
exception = subsegment.cause["exceptions"][0]
155+
assert exception.type == "UnsupportedProtocol"
156+
157+
158+
@pytest.mark.parametrize("use_client", (True, False))
159+
def test_name_uses_hostname(use_client):
160+
if use_client:
161+
client = httpx.Client()
162+
else:
163+
client = httpx
164+
165+
try:
166+
url1 = "http://{}/fakepath/stuff/koo/lai/ahh".format(BASE_URL)
167+
client.get(url1)
168+
subsegment = xray_recorder.current_segment().subsegments[-1]
169+
assert subsegment.namespace == "remote"
170+
assert subsegment.name == BASE_URL
171+
http_meta1 = subsegment.http
172+
assert http_meta1["request"]["url"] == strip_url(url1)
173+
assert http_meta1["request"]["method"].upper() == "GET"
174+
175+
url2 = "http://{}/".format(BASE_URL)
176+
client.get(url2, params={"some": "payload", "not": "toBeIncluded"})
177+
subsegment = xray_recorder.current_segment().subsegments[-1]
178+
assert subsegment.namespace == "remote"
179+
assert subsegment.name == BASE_URL
180+
http_meta2 = subsegment.http
181+
assert http_meta2["request"]["url"] == strip_url(url2)
182+
assert http_meta2["request"]["method"].upper() == "GET"
183+
184+
url3 = "http://subdomain.{}/fakepath/stuff/koo/lai/ahh".format(BASE_URL)
185+
try:
186+
client.get(url3)
187+
except httpx.ConnectError:
188+
pass
189+
subsegment = xray_recorder.current_segment().subsegments[-1]
190+
assert subsegment.namespace == "remote"
191+
assert subsegment.name == "subdomain." + BASE_URL
192+
http_meta3 = subsegment.http
193+
assert http_meta3["request"]["url"] == strip_url(url3)
194+
assert http_meta3["request"]["method"].upper() == "GET"
195+
finally:
196+
if use_client:
197+
client.close()
198+
199+
200+
@pytest.mark.parametrize("use_client", (True, False))
201+
def test_strip_http_url(use_client):
202+
status_code = 200
203+
url = "http://{}/get?foo=bar".format(BASE_URL)
204+
if use_client:
205+
with httpx.Client() as client:
206+
response = client.get(url)
207+
else:
208+
response = httpx.get(url)
209+
assert "x-amzn-trace-id" in response._request.headers
210+
211+
subsegment = xray_recorder.current_segment().subsegments[0]
212+
assert subsegment.namespace == "remote"
213+
assert subsegment.name == get_hostname(url)
214+
215+
http_meta = subsegment.http
216+
assert http_meta["request"]["url"] == strip_url(url)
217+
assert http_meta["request"]["method"].upper() == "GET"
218+
assert http_meta["response"]["status"] == status_code

0 commit comments

Comments
 (0)