Skip to content

Commit 9206e5d

Browse files
authored
Refactor httpx instrumentation (#577)
1 parent bf6fd08 commit 9206e5d

File tree

3 files changed

+116
-120
lines changed

3 files changed

+116
-120
lines changed

CHANGELOG.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.3.0-0.22b0...HEAD)
9-
- `opentelemetry-sdk-extension-aws` Update AWS entry points to match spec
10-
([#566](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/566))
11-
- Include Flask 2.0 as compatible with existing flask instrumentation
12-
([#545](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/545))
13-
- `openelemetry-sdk-extension-aws` Take a dependency on `opentelemetry-sdk`
14-
([#558](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/558))
159

1610
### Changed
1711
- `opentelemetry-instrumentation-tornado` properly instrument work done in tornado on_finish method.
@@ -36,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3630
([#567](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/567))
3731
- `opentelemetry-instrumentation-grpc` Fixed asynchonous unary call traces
3832
([#536](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/536))
33+
- `opentelemetry-sdk-extension-aws` Update AWS entry points to match spec
34+
([#566](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/566))
35+
- Include Flask 2.0 as compatible with existing flask instrumentation
36+
([#545](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/545))
37+
- `openelemetry-sdk-extension-aws` Take a dependency on `opentelemetry-sdk`
38+
([#558](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/558))
39+
- Change `opentelemetry-instrumentation-httpx` to replace `client` classes with instrumented versions.
40+
([#577](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/577))
3941

4042
### Added
4143
- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation

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

Lines changed: 91 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import logging
1516
import typing
1617

1718
import httpx
@@ -31,6 +32,8 @@
3132
from opentelemetry.trace.span import Span
3233
from opentelemetry.trace.status import Status
3334

35+
_logger = logging.getLogger(__name__)
36+
3437
URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes]
3538
Headers = typing.List[typing.Tuple[bytes, bytes]]
3639
RequestHook = typing.Callable[[Span, "RequestInfo"], None]
@@ -258,98 +261,48 @@ async def handle_async_request(
258261
return status_code, headers, stream, extensions
259262

260263

261-
def _instrument(
262-
tracer_provider: TracerProvider = None,
263-
request_hook: typing.Optional[RequestHook] = None,
264-
response_hook: typing.Optional[ResponseHook] = None,
265-
) -> None:
266-
"""Enables tracing of all Client and AsyncClient instances
267-
268-
When a Client or AsyncClient gets created, a telemetry transport is passed
269-
in to the instance.
270-
"""
271-
# pylint:disable=unused-argument
272-
def instrumented_sync_send(wrapped, instance, args, kwargs):
273-
if context.get_value("suppress_instrumentation"):
274-
return wrapped(*args, **kwargs)
264+
class _InstrumentedClient(httpx.Client):
275265

276-
transport = instance._transport or httpx.HTTPTransport()
277-
telemetry_transport = SyncOpenTelemetryTransport(
278-
transport,
279-
tracer_provider=tracer_provider,
280-
request_hook=request_hook,
281-
response_hook=response_hook,
282-
)
266+
_tracer_provider = None
267+
_request_hook = None
268+
_response_hook = None
283269

284-
instance._transport = telemetry_transport
285-
return wrapped(*args, **kwargs)
270+
def __init__(self, *args, **kwargs):
271+
super().__init__(*args, **kwargs)
286272

287-
async def instrumented_async_send(wrapped, instance, args, kwargs):
288-
if context.get_value("suppress_instrumentation"):
289-
return await wrapped(*args, **kwargs)
273+
self._original_transport = self._transport
274+
self._is_instrumented_by_opentelemetry = True
290275

291-
transport = instance._transport or httpx.AsyncHTTPTransport()
292-
telemetry_transport = AsyncOpenTelemetryTransport(
293-
transport,
294-
tracer_provider=tracer_provider,
295-
request_hook=request_hook,
296-
response_hook=response_hook,
276+
self._transport = SyncOpenTelemetryTransport(
277+
self._transport,
278+
tracer_provider=_InstrumentedClient._tracer_provider,
279+
request_hook=_InstrumentedClient._request_hook,
280+
response_hook=_InstrumentedClient._response_hook,
297281
)
298282

299-
instance._transport = telemetry_transport
300-
return await wrapped(*args, **kwargs)
301283

302-
wrapt.wrap_function_wrapper(httpx.Client, "send", instrumented_sync_send)
284+
class _InstrumentedAsyncClient(httpx.AsyncClient):
303285

304-
wrapt.wrap_function_wrapper(
305-
httpx.AsyncClient, "send", instrumented_async_send
306-
)
286+
_tracer_provider = None
287+
_request_hook = None
288+
_response_hook = None
307289

290+
def __init__(self, *args, **kwargs):
291+
super().__init__(*args, **kwargs)
308292

309-
def _instrument_client(
310-
client: typing.Union[httpx.Client, httpx.AsyncClient],
311-
tracer_provider: TracerProvider = None,
312-
request_hook: typing.Optional[RequestHook] = None,
313-
response_hook: typing.Optional[ResponseHook] = None,
314-
) -> None:
315-
"""Enables instrumentation for the given Client or AsyncClient"""
316-
# pylint: disable=protected-access
317-
if isinstance(client, httpx.Client):
318-
transport = client._transport or httpx.HTTPTransport()
319-
telemetry_transport = SyncOpenTelemetryTransport(
320-
transport,
321-
tracer_provider=tracer_provider,
322-
request_hook=request_hook,
323-
response_hook=response_hook,
324-
)
325-
elif isinstance(client, httpx.AsyncClient):
326-
transport = client._transport or httpx.AsyncHTTPTransport()
327-
telemetry_transport = AsyncOpenTelemetryTransport(
328-
transport,
329-
tracer_provider=tracer_provider,
330-
request_hook=request_hook,
331-
response_hook=response_hook,
332-
)
333-
else:
334-
raise TypeError("Invalid client provided")
335-
client._transport = telemetry_transport
293+
self._original_transport = self._transport
294+
self._is_instrumented_by_opentelemetry = True
336295

337-
338-
def _uninstrument() -> None:
339-
"""Disables instrumenting for all newly created Client and AsyncClient instances"""
340-
unwrap(httpx.Client, "send")
341-
unwrap(httpx.AsyncClient, "send")
342-
343-
344-
def _uninstrument_client(
345-
client: typing.Union[httpx.Client, httpx.AsyncClient]
346-
) -> None:
347-
"""Disables instrumentation for the given Client or AsyncClient"""
348-
# pylint: disable=protected-access
349-
unwrap(client, "send")
296+
self._transport = AsyncOpenTelemetryTransport(
297+
self._transport,
298+
tracer_provider=_InstrumentedAsyncClient._tracer_provider,
299+
request_hook=_InstrumentedAsyncClient._request_hook,
300+
response_hook=_InstrumentedAsyncClient._response_hook,
301+
)
350302

351303

352304
class HTTPXClientInstrumentor(BaseInstrumentor):
305+
# pylint: disable=protected-access,attribute-defined-outside-init
353306
"""An instrumentor for httpx Client and AsyncClient
354307
355308
See `BaseInstrumentor`
@@ -369,14 +322,31 @@ def _instrument(self, **kwargs):
369322
``response_hook``: A hook that receives the span, request, and response
370323
that is called right before the span ends
371324
"""
372-
_instrument(
373-
tracer_provider=kwargs.get("tracer_provider"),
374-
request_hook=kwargs.get("request_hook"),
375-
response_hook=kwargs.get("response_hook"),
376-
)
325+
self._original_client = httpx.Client
326+
self._original_async_client = httpx.AsyncClient
327+
request_hook = kwargs.get("request_hook")
328+
response_hook = kwargs.get("response_hook")
329+
if callable(request_hook):
330+
_InstrumentedClient._request_hook = request_hook
331+
_InstrumentedAsyncClient._request_hook = request_hook
332+
if callable(response_hook):
333+
_InstrumentedClient._response_hook = response_hook
334+
_InstrumentedAsyncClient._response_hook = response_hook
335+
tracer_provider = kwargs.get("tracer_provider")
336+
_InstrumentedClient._tracer_provider = tracer_provider
337+
_InstrumentedAsyncClient._tracer_provider = tracer_provider
338+
httpx.Client = _InstrumentedClient
339+
httpx.AsyncClient = _InstrumentedAsyncClient
377340

378341
def _uninstrument(self, **kwargs):
379-
_uninstrument()
342+
httpx.Client = self._original_client
343+
httpx.AsyncClient = self._original_async_client
344+
_InstrumentedClient._tracer_provider = None
345+
_InstrumentedClient._request_hook = None
346+
_InstrumentedClient._response_hook = None
347+
_InstrumentedAsyncClient._tracer_provider = None
348+
_InstrumentedAsyncClient._request_hook = None
349+
_InstrumentedAsyncClient._response_hook = None
380350

381351
@staticmethod
382352
def instrument_client(
@@ -395,12 +365,34 @@ def instrument_client(
395365
response_hook: A hook that receives the span, request, and response
396366
that is called right before the span ends
397367
"""
398-
_instrument_client(
399-
client,
400-
tracer_provider=tracer_provider,
401-
request_hook=request_hook,
402-
response_hook=response_hook,
403-
)
368+
# pylint: disable=protected-access
369+
if not hasattr(client, "_is_instrumented_by_opentelemetry"):
370+
client._is_instrumented_by_opentelemetry = False
371+
372+
if not client._is_instrumented_by_opentelemetry:
373+
if isinstance(client, httpx.Client):
374+
client._original_transport = client._transport
375+
transport = client._transport or httpx.HTTPTransport()
376+
client._transport = SyncOpenTelemetryTransport(
377+
transport,
378+
tracer_provider=tracer_provider,
379+
request_hook=request_hook,
380+
response_hook=response_hook,
381+
)
382+
client._is_instrumented_by_opentelemetry = True
383+
if isinstance(client, httpx.AsyncClient):
384+
transport = client._transport or httpx.AsyncHTTPTransport()
385+
client._transport = AsyncOpenTelemetryTransport(
386+
transport,
387+
tracer_provider=tracer_provider,
388+
request_hook=request_hook,
389+
response_hook=response_hook,
390+
)
391+
client._is_instrumented_by_opentelemetry = True
392+
else:
393+
_logger.warning(
394+
"Attempting to instrument Httpx client while already instrumented"
395+
)
404396

405397
@staticmethod
406398
def uninstrument_client(
@@ -411,4 +403,12 @@ def uninstrument_client(
411403
Args:
412404
client: The httpx Client or AsyncClient instance
413405
"""
414-
_uninstrument_client(client)
406+
if hasattr(client, "_original_transport"):
407+
client._transport = client._original_transport
408+
del client._original_transport
409+
client._is_instrumented_by_opentelemetry = False
410+
else:
411+
_logger.warning(
412+
"Attempting to uninstrument Httpx "
413+
"client while already uninstrumented"
414+
)

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

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ def test_basic(self):
157157
span, opentelemetry.instrumentation.httpx
158158
)
159159

160+
def test_basic_multiple(self):
161+
self.perform_request(self.URL)
162+
self.perform_request(self.URL)
163+
self.assert_span(num_spans=2)
164+
160165
def test_not_foundbasic(self):
161166
url_404 = "http://httpbin.org/status/404"
162167

@@ -375,20 +380,16 @@ def create_client(
375380
pass
376381

377382
def setUp(self):
378-
self.client = self.create_client()
379-
HTTPXClientInstrumentor().instrument()
380383
super().setUp()
381-
382-
def tearDown(self):
383-
super().tearDown()
384+
HTTPXClientInstrumentor().instrument()
385+
self.client = self.create_client()
384386
HTTPXClientInstrumentor().uninstrument()
385387

386388
def test_custom_tracer_provider(self):
387389
resource = resources.Resource.create({})
388390
result = self.create_tracer_provider(resource=resource)
389391
tracer_provider, exporter = result
390392

391-
HTTPXClientInstrumentor().uninstrument()
392393
HTTPXClientInstrumentor().instrument(
393394
tracer_provider=tracer_provider
394395
)
@@ -398,9 +399,9 @@ def test_custom_tracer_provider(self):
398399
self.assertEqual(result.text, "Hello!")
399400
span = self.assert_span(exporter=exporter)
400401
self.assertIs(span.resource, resource)
402+
HTTPXClientInstrumentor().uninstrument()
401403

402404
def test_response_hook(self):
403-
HTTPXClientInstrumentor().uninstrument()
404405
HTTPXClientInstrumentor().instrument(
405406
tracer_provider=self.tracer_provider,
406407
response_hook=self.response_hook,
@@ -419,9 +420,9 @@ def test_response_hook(self):
419420
HTTP_RESPONSE_BODY: "Hello!",
420421
},
421422
)
423+
HTTPXClientInstrumentor().uninstrument()
422424

423425
def test_request_hook(self):
424-
HTTPXClientInstrumentor().uninstrument()
425426
HTTPXClientInstrumentor().instrument(
426427
tracer_provider=self.tracer_provider,
427428
request_hook=self.request_hook,
@@ -432,9 +433,9 @@ def test_request_hook(self):
432433
self.assertEqual(result.text, "Hello!")
433434
span = self.assert_span()
434435
self.assertEqual(span.name, "GET" + self.URL)
436+
HTTPXClientInstrumentor().uninstrument()
435437

436438
def test_request_hook_no_span_update(self):
437-
HTTPXClientInstrumentor().uninstrument()
438439
HTTPXClientInstrumentor().instrument(
439440
tracer_provider=self.tracer_provider,
440441
request_hook=self.no_update_request_hook,
@@ -445,10 +446,10 @@ def test_request_hook_no_span_update(self):
445446
self.assertEqual(result.text, "Hello!")
446447
span = self.assert_span()
447448
self.assertEqual(span.name, "HTTP GET")
449+
HTTPXClientInstrumentor().uninstrument()
448450

449451
def test_not_recording(self):
450452
with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span:
451-
HTTPXClientInstrumentor().uninstrument()
452453
HTTPXClientInstrumentor().instrument(
453454
tracer_provider=trace._DefaultTracerProvider()
454455
)
@@ -463,8 +464,10 @@ def test_not_recording(self):
463464
self.assertTrue(mock_span.is_recording.called)
464465
self.assertFalse(mock_span.set_attribute.called)
465466
self.assertFalse(mock_span.set_status.called)
467+
HTTPXClientInstrumentor().uninstrument()
466468

467469
def test_suppress_instrumentation_new_client(self):
470+
HTTPXClientInstrumentor().instrument()
468471
token = context.attach(
469472
context.set_value("suppress_instrumentation", True)
470473
)
@@ -476,32 +479,22 @@ def test_suppress_instrumentation_new_client(self):
476479
context.detach(token)
477480

478481
self.assert_span(num_spans=0)
479-
480-
def test_existing_client(self):
481482
HTTPXClientInstrumentor().uninstrument()
482-
client = self.create_client()
483-
HTTPXClientInstrumentor().instrument()
484-
result = self.perform_request(self.URL, client=client)
485-
self.assertEqual(result.text, "Hello!")
486-
self.assert_span(num_spans=1)
487483

488484
def test_instrument_client(self):
489-
HTTPXClientInstrumentor().uninstrument()
490485
client = self.create_client()
491486
HTTPXClientInstrumentor().instrument_client(client)
492487
result = self.perform_request(self.URL, client=client)
493488
self.assertEqual(result.text, "Hello!")
494489
self.assert_span(num_spans=1)
495-
# instrument again to avoid annoying warning message
496-
HTTPXClientInstrumentor().instrument()
497490

498491
def test_uninstrument(self):
492+
HTTPXClientInstrumentor().instrument()
499493
HTTPXClientInstrumentor().uninstrument()
500-
result = self.perform_request(self.URL)
494+
client = self.create_client()
495+
result = self.perform_request(self.URL, client=client)
501496
self.assertEqual(result.text, "Hello!")
502497
self.assert_span(num_spans=0)
503-
# instrument again to avoid annoying warning message
504-
HTTPXClientInstrumentor().instrument()
505498

506499
def test_uninstrument_client(self):
507500
HTTPXClientInstrumentor().uninstrument_client(self.client)
@@ -512,6 +505,7 @@ def test_uninstrument_client(self):
512505
self.assert_span(num_spans=0)
513506

514507
def test_uninstrument_new_client(self):
508+
HTTPXClientInstrumentor().instrument()
515509
client1 = self.create_client()
516510
HTTPXClientInstrumentor().uninstrument_client(client1)
517511

0 commit comments

Comments
 (0)