Skip to content

Commit 0b9e96d

Browse files
authored
Support newer httpx versions (#866)
1 parent 8d309af commit 0b9e96d

File tree

10 files changed

+129
-82
lines changed

10 files changed

+129
-82
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4141
- `opentelemetry-instrumentation-pymongo` now supports `pymongo v4`
4242
([#876](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/876))
4343

44+
- `opentelemetry-instrumentation-httpx` now supports versions higher than `0.19.0`.
45+
([#866](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/866))
46+
4447
### Fixed
4548

4649
- `opentelemetry-instrumentation-django` Django: Conditionally create SERVER spans

docs-requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ redis>=2.6
3636
sqlalchemy>=1.0
3737
tornado>=5.1.1
3838
ddtrace>=0.34.0
39-
httpx~=0.18.0
39+
httpx>=0.18.0

docs/nitpick-exceptions.ini

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class_references=
2525
httpx.AsyncBaseTransport
2626
httpx.SyncByteStream
2727
httpx.AsyncByteStream
28+
httpx.Response
2829
yarl.URL
2930

3031
anys=

instrumentation/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 |
1717
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 |
1818
| [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio ~= 1.27 |
19-
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0, < 0.19.0 |
19+
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 |
2020
| [opentelemetry-instrumentation-jinja2](./opentelemetry-instrumentation-jinja2) | jinja2 >= 2.7, < 4.0 |
2121
| [opentelemetry-instrumentation-kafka-python](./opentelemetry-instrumentation-kafka-python) | kafka-python >= 2.0 |
2222
| [opentelemetry-instrumentation-logging](./opentelemetry-instrumentation-logging) | logging |

instrumentation/opentelemetry-instrumentation-httpx/setup.cfg

-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ install_requires =
4848
test =
4949
opentelemetry-sdk ~= 1.3
5050
opentelemetry-test-utils == 0.28b1
51-
respx ~= 0.17.0
5251

5352
[options.packages.find]
5453
where = src

instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py

+85-66
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,38 @@ def _prepare_headers(headers: typing.Optional[Headers]) -> httpx.Headers:
233233
return httpx.Headers(headers)
234234

235235

236+
def _extract_parameters(args, kwargs):
237+
if isinstance(args[0], httpx.Request):
238+
# In httpx >= 0.20.0, handle_request receives a Request object
239+
request: httpx.Request = args[0]
240+
method = request.method.encode()
241+
url = request.url
242+
headers = request.headers
243+
stream = request.stream
244+
extensions = request.extensions
245+
else:
246+
# In httpx < 0.20.0, handle_request receives the parameters separately
247+
method = args[0]
248+
url = args[1]
249+
headers = kwargs.get("headers", args[2] if len(args) > 2 else None)
250+
stream = kwargs.get("stream", args[3] if len(args) > 3 else None)
251+
extensions = kwargs.get(
252+
"extensions", args[4] if len(args) > 4 else None
253+
)
254+
255+
return method, url, headers, stream, extensions
256+
257+
258+
def _inject_propagation_headers(headers, args, kwargs):
259+
_headers = _prepare_headers(headers)
260+
inject(_headers)
261+
if isinstance(args[0], httpx.Request):
262+
request: httpx.Request = args[0]
263+
request.headers = _headers
264+
else:
265+
kwargs["headers"] = _headers.raw
266+
267+
236268
class SyncOpenTelemetryTransport(httpx.BaseTransport):
237269
"""Sync transport class that will trace all requests made with a client.
238270
@@ -263,60 +295,53 @@ def __init__(
263295

264296
def handle_request(
265297
self,
266-
method: bytes,
267-
url: URL,
268-
headers: typing.Optional[Headers] = None,
269-
stream: typing.Optional[httpx.SyncByteStream] = None,
270-
extensions: typing.Optional[dict] = None,
271-
) -> typing.Tuple[int, "Headers", httpx.SyncByteStream, dict]:
298+
*args,
299+
**kwargs,
300+
) -> typing.Union[
301+
typing.Tuple[int, "Headers", httpx.SyncByteStream, dict],
302+
httpx.Response,
303+
]:
272304
"""Add request info to span."""
273305
if context.get_value("suppress_instrumentation"):
274-
return self._transport.handle_request(
275-
method,
276-
url,
277-
headers=headers,
278-
stream=stream,
279-
extensions=extensions,
280-
)
306+
return self._transport.handle_request(*args, **kwargs)
281307

308+
method, url, headers, stream, extensions = _extract_parameters(
309+
args, kwargs
310+
)
282311
span_attributes = _prepare_attributes(method, url)
283-
_headers = _prepare_headers(headers)
312+
313+
request_info = RequestInfo(method, url, headers, stream, extensions)
284314
span_name = _get_default_span_name(
285315
span_attributes[SpanAttributes.HTTP_METHOD]
286316
)
287-
request = RequestInfo(method, url, headers, stream, extensions)
288317

289318
with self._tracer.start_as_current_span(
290319
span_name, kind=SpanKind.CLIENT, attributes=span_attributes
291320
) as span:
292321
if self._request_hook is not None:
293-
self._request_hook(span, request)
294-
295-
inject(_headers)
296-
297-
(
298-
status_code,
299-
headers,
300-
stream,
301-
extensions,
302-
) = self._transport.handle_request(
303-
method,
304-
url,
305-
headers=_headers.raw,
306-
stream=stream,
307-
extensions=extensions,
308-
)
322+
self._request_hook(span, request_info)
323+
324+
_inject_propagation_headers(headers, args, kwargs)
325+
response = self._transport.handle_request(*args, **kwargs)
326+
if isinstance(response, httpx.Response):
327+
response: httpx.Response = response
328+
status_code = response.status_code
329+
headers = response.headers
330+
stream = response.stream
331+
extensions = response.extensions
332+
else:
333+
status_code, headers, stream, extensions = response
309334

310335
_apply_status_code(span, status_code)
311336

312337
if self._response_hook is not None:
313338
self._response_hook(
314339
span,
315-
request,
340+
request_info,
316341
ResponseInfo(status_code, headers, stream, extensions),
317342
)
318343

319-
return status_code, headers, stream, extensions
344+
return response
320345

321346

322347
class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
@@ -348,61 +373,55 @@ def __init__(
348373
self._response_hook = response_hook
349374

350375
async def handle_async_request(
351-
self,
352-
method: bytes,
353-
url: URL,
354-
headers: typing.Optional[Headers] = None,
355-
stream: typing.Optional[httpx.AsyncByteStream] = None,
356-
extensions: typing.Optional[dict] = None,
357-
) -> typing.Tuple[int, "Headers", httpx.AsyncByteStream, dict]:
376+
self, *args, **kwargs
377+
) -> typing.Union[
378+
typing.Tuple[int, "Headers", httpx.AsyncByteStream, dict],
379+
httpx.Response,
380+
]:
358381
"""Add request info to span."""
359382
if context.get_value("suppress_instrumentation"):
360-
return await self._transport.handle_async_request(
361-
method,
362-
url,
363-
headers=headers,
364-
stream=stream,
365-
extensions=extensions,
366-
)
383+
return await self._transport.handle_async_request(*args, **kwargs)
367384

385+
method, url, headers, stream, extensions = _extract_parameters(
386+
args, kwargs
387+
)
368388
span_attributes = _prepare_attributes(method, url)
369-
_headers = _prepare_headers(headers)
389+
370390
span_name = _get_default_span_name(
371391
span_attributes[SpanAttributes.HTTP_METHOD]
372392
)
373-
request = RequestInfo(method, url, headers, stream, extensions)
393+
request_info = RequestInfo(method, url, headers, stream, extensions)
374394

375395
with self._tracer.start_as_current_span(
376396
span_name, kind=SpanKind.CLIENT, attributes=span_attributes
377397
) as span:
378398
if self._request_hook is not None:
379-
await self._request_hook(span, request)
380-
381-
inject(_headers)
382-
383-
(
384-
status_code,
385-
headers,
386-
stream,
387-
extensions,
388-
) = await self._transport.handle_async_request(
389-
method,
390-
url,
391-
headers=_headers.raw,
392-
stream=stream,
393-
extensions=extensions,
399+
await self._request_hook(span, request_info)
400+
401+
_inject_propagation_headers(headers, args, kwargs)
402+
403+
response = await self._transport.handle_async_request(
404+
*args, **kwargs
394405
)
406+
if isinstance(response, httpx.Response):
407+
response: httpx.Response = response
408+
status_code = response.status_code
409+
headers = response.headers
410+
stream = response.stream
411+
extensions = response.extensions
412+
else:
413+
status_code, headers, stream, extensions = response
395414

396415
_apply_status_code(span, status_code)
397416

398417
if self._response_hook is not None:
399418
await self._response_hook(
400419
span,
401-
request,
420+
request_info,
402421
ResponseInfo(status_code, headers, stream, extensions),
403422
)
404423

405-
return status_code, headers, stream, extensions
424+
return response
406425

407426

408427
class _InstrumentedClient(httpx.Client):

instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
# limitations under the License.
1414

1515

16-
_instruments = ("httpx >= 0.18.0, < 0.19.0",)
16+
_instruments = ("httpx >= 0.18.0",)

instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def test_requests_timeout_exception(self):
250250
self.assertEqual(span.status.status_code, StatusCode.ERROR)
251251

252252
def test_invalid_url(self):
253-
url = "invalid://nope"
253+
url = "invalid://nope/"
254254

255255
with respx.mock, self.assertRaises(httpx.UnsupportedProtocol):
256256
respx.post("invalid://nope").pass_through()
@@ -259,14 +259,10 @@ def test_invalid_url(self):
259259
span = self.assert_span()
260260

261261
self.assertEqual(span.name, "HTTP POST")
262-
print(span.attributes)
263262
self.assertEqual(
264-
span.attributes,
265-
{
266-
SpanAttributes.HTTP_METHOD: "POST",
267-
SpanAttributes.HTTP_URL: "invalid://nope/",
268-
},
263+
span.attributes[SpanAttributes.HTTP_METHOD], "POST"
269264
)
265+
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], url)
270266
self.assertEqual(span.status.status_code, StatusCode.ERROR)
271267

272268
def test_if_headers_equals_none(self):
@@ -621,6 +617,17 @@ async def _perform_request():
621617

622618
return _async_call(_perform_request())
623619

620+
def test_basic_multiple(self):
621+
# We need to create separate clients because in httpx >= 0.19,
622+
# closing the client after "with" means the second http call fails
623+
self.perform_request(
624+
self.URL, client=self.create_client(self.transport)
625+
)
626+
self.perform_request(
627+
self.URL, client=self.create_client(self.transport)
628+
)
629+
self.assert_span(num_spans=2)
630+
624631

625632
class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):
626633
def create_client(
@@ -646,6 +653,13 @@ class TestAsyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):
646653
request_hook = staticmethod(_async_request_hook)
647654
no_update_request_hook = staticmethod(_async_no_update_request_hook)
648655

656+
def setUp(self):
657+
super().setUp()
658+
HTTPXClientInstrumentor().instrument()
659+
self.client = self.create_client()
660+
self.client2 = self.create_client()
661+
HTTPXClientInstrumentor().uninstrument()
662+
649663
def create_client(
650664
self,
651665
transport: typing.Optional[AsyncOpenTelemetryTransport] = None,
@@ -668,3 +682,10 @@ async def _perform_request():
668682
return await _client.request(method, url, headers=headers)
669683

670684
return _async_call(_perform_request())
685+
686+
def test_basic_multiple(self):
687+
# We need to create separate clients because in httpx >= 0.19,
688+
# closing the client after "with" means the second http call fails
689+
self.perform_request(self.URL, client=self.client)
690+
self.perform_request(self.URL, client=self.client2)
691+
self.assert_span(num_spans=2)

opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"instrumentation": "opentelemetry-instrumentation-grpc==0.28b1",
7070
},
7171
"httpx": {
72-
"library": "httpx >= 0.18.0, < 0.19.0",
72+
"library": "httpx >= 0.18.0",
7373
"instrumentation": "opentelemetry-instrumentation-httpx==0.28b1",
7474
},
7575
"jinja2": {

tox.ini

+8-4
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ envlist =
166166
pypy3-test-instrumentation-tornado
167167

168168
; opentelemetry-instrumentation-httpx
169-
py3{6,7,8,9,10}-test-instrumentation-httpx
170-
pypy3-test-instrumentation-httpx
169+
py3{6,7,8,9,10}-test-instrumentation-httpx{18,21}
170+
pypy3-test-instrumentation-httpx{18,21}
171171

172172
; opentelemetry-util-http
173173
py3{6,7,8,9,10}-test-util-http
@@ -222,6 +222,10 @@ deps =
222222
sqlalchemy14: sqlalchemy~=1.4
223223
pika0: pika>=0.12.0,<1.0.0
224224
pika1: pika>=1.0.0
225+
httpx18: httpx>=0.18.0,<0.19.0
226+
httpx18: respx~=0.17.0
227+
httpx21: httpx>=0.19.0
228+
httpx21: respx~=0.19.0
225229

226230
; FIXME: add coverage testing
227231
; FIXME: add mypy testing
@@ -270,7 +274,7 @@ changedir =
270274
test-instrumentation-starlette: instrumentation/opentelemetry-instrumentation-starlette/tests
271275
test-instrumentation-tornado: instrumentation/opentelemetry-instrumentation-tornado/tests
272276
test-instrumentation-wsgi: instrumentation/opentelemetry-instrumentation-wsgi/tests
273-
test-instrumentation-httpx: instrumentation/opentelemetry-instrumentation-httpx/tests
277+
test-instrumentation-httpx{18,21}: instrumentation/opentelemetry-instrumentation-httpx/tests
274278
test-util-http: util/opentelemetry-util-http/tests
275279
test-sdkextension-aws: sdk-extension/opentelemetry-sdk-extension-aws/tests
276280
test-propagator-aws: propagator/opentelemetry-propagator-aws-xray/tests
@@ -366,7 +370,7 @@ commands_pre =
366370

367371
elasticsearch{2,5,6}: pip install {toxinidir}/opentelemetry-instrumentation[test] {toxinidir}/instrumentation/opentelemetry-instrumentation-elasticsearch[test]
368372

369-
httpx: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test]
373+
httpx{18,21}: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[test]
370374

371375
sdkextension-aws: pip install {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws[test]
372376

0 commit comments

Comments
 (0)