From 6f39cf188cb9930c0dca7b57f75ee4c3ccac238d Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 21:02:02 -0400 Subject: [PATCH 01/10] httpx2 instrumentation support --- .changelog/4730.added | 1 + .github/workflows/core_contrib_test.yml | 120 ++++++ .github/workflows/test.yml | 380 ++++++++++++++++++ instrumentation/README.md | 2 +- .../README.rst | 49 ++- .../pyproject.toml | 5 +- .../instrumentation/httpx/__init__.py | 219 +++++++--- .../instrumentation/httpx/package.py | 5 +- .../test-requirements-2-wrapt1.txt | 2 + .../test-requirements-2-wrapt2.txt | 2 + .../test-requirements-2.txt | 5 + .../test-requirements-3-wrapt1.txt | 2 + .../test-requirements-3-wrapt2.txt | 2 + .../test-requirements-3.txt | 7 + .../tests/test_httpx2_integration.py | 279 +++++++++++++ .../instrumentation/bootstrap_gen.py | 4 + pyproject.toml | 2 +- tox.ini | 12 +- uv.lock | 50 ++- 19 files changed, 1089 insertions(+), 59 deletions(-) create mode 100644 .changelog/4730.added create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt1.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt2.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt1.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt2.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3.txt create mode 100644 instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx2_integration.py diff --git a/.changelog/4730.added b/.changelog/4730.added new file mode 100644 index 0000000000..7b0b6602bd --- /dev/null +++ b/.changelog/4730.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-httpx`: add support for httpx2 diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index 8a17c71a13..dd66ecf055 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -3027,6 +3027,126 @@ jobs: - name: Run tests run: tox -e py310-test-instrumentation-httpx-1-wrapt2 -- -ra + py310-test-instrumentation-httpx-2-wrapt1: + name: instrumentation-httpx-2-wrapt1 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-2-wrapt1 -- -ra + + py310-test-instrumentation-httpx-2-wrapt2: + name: instrumentation-httpx-2-wrapt2 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-2-wrapt2 -- -ra + + py310-test-instrumentation-httpx-3-wrapt1: + name: instrumentation-httpx-3-wrapt1 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-3-wrapt1 -- -ra + + py310-test-instrumentation-httpx-3-wrapt2: + name: instrumentation-httpx-3-wrapt2 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-3-wrapt2 -- -ra + py310-test-util-http: name: util-http runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4521c240d9..fdce03aa0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10208,6 +10208,386 @@ jobs: - name: Run tests run: tox -e py312-test-instrumentation-httpx-1-wrapt2 -- -ra + py310-test-instrumentation-httpx-2-wrapt1_ubuntu-latest: + name: instrumentation-httpx-2-wrapt1 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-2-wrapt1 -- -ra + + py310-test-instrumentation-httpx-2-wrapt2_ubuntu-latest: + name: instrumentation-httpx-2-wrapt2 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-2-wrapt2 -- -ra + + py311-test-instrumentation-httpx-2-wrapt1_ubuntu-latest: + name: instrumentation-httpx-2-wrapt1 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-httpx-2-wrapt1 -- -ra + + py311-test-instrumentation-httpx-2-wrapt2_ubuntu-latest: + name: instrumentation-httpx-2-wrapt2 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-httpx-2-wrapt2 -- -ra + + py312-test-instrumentation-httpx-2-wrapt1_ubuntu-latest: + name: instrumentation-httpx-2-wrapt1 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-httpx-2-wrapt1 -- -ra + + py312-test-instrumentation-httpx-2-wrapt2_ubuntu-latest: + name: instrumentation-httpx-2-wrapt2 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-httpx-2-wrapt2 -- -ra + + py313-test-instrumentation-httpx-2-wrapt1_ubuntu-latest: + name: instrumentation-httpx-2-wrapt1 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-httpx-2-wrapt1 -- -ra + + py313-test-instrumentation-httpx-2-wrapt2_ubuntu-latest: + name: instrumentation-httpx-2-wrapt2 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-httpx-2-wrapt2 -- -ra + + py314-test-instrumentation-httpx-2-wrapt1_ubuntu-latest: + name: instrumentation-httpx-2-wrapt1 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-httpx-2-wrapt1 -- -ra + + py314-test-instrumentation-httpx-2-wrapt2_ubuntu-latest: + name: instrumentation-httpx-2-wrapt2 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-httpx-2-wrapt2 -- -ra + + py310-test-instrumentation-httpx-3-wrapt1_ubuntu-latest: + name: instrumentation-httpx-3-wrapt1 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-3-wrapt1 -- -ra + + py310-test-instrumentation-httpx-3-wrapt2_ubuntu-latest: + name: instrumentation-httpx-3-wrapt2 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-httpx-3-wrapt2 -- -ra + + py311-test-instrumentation-httpx-3-wrapt1_ubuntu-latest: + name: instrumentation-httpx-3-wrapt1 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-httpx-3-wrapt1 -- -ra + + py311-test-instrumentation-httpx-3-wrapt2_ubuntu-latest: + name: instrumentation-httpx-3-wrapt2 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-httpx-3-wrapt2 -- -ra + + py312-test-instrumentation-httpx-3-wrapt1_ubuntu-latest: + name: instrumentation-httpx-3-wrapt1 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-httpx-3-wrapt1 -- -ra + + py312-test-instrumentation-httpx-3-wrapt2_ubuntu-latest: + name: instrumentation-httpx-3-wrapt2 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-httpx-3-wrapt2 -- -ra + + py313-test-instrumentation-httpx-3-wrapt1_ubuntu-latest: + name: instrumentation-httpx-3-wrapt1 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-httpx-3-wrapt1 -- -ra + + py313-test-instrumentation-httpx-3-wrapt2_ubuntu-latest: + name: instrumentation-httpx-3-wrapt2 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-httpx-3-wrapt2 -- -ra + + py314-test-instrumentation-httpx-3-wrapt1_ubuntu-latest: + name: instrumentation-httpx-3-wrapt1 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-httpx-3-wrapt1 -- -ra + + py314-test-instrumentation-httpx-3-wrapt2_ubuntu-latest: + name: instrumentation-httpx-3-wrapt2 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-httpx-3-wrapt2 -- -ra + py313-test-instrumentation-httpx-1-wrapt1_ubuntu-latest: name: instrumentation-httpx-1-wrapt1 3.13 Ubuntu runs-on: ubuntu-latest diff --git a/instrumentation/README.md b/instrumentation/README.md index 64766ac5c2..4721484fe7 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -24,7 +24,7 @@ | [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.92 | Yes | migration | [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0 | Yes | migration | [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio >= 1.42.0 | No | development -| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | Yes | migration +| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0,httpx2 >= 2.0.0 | Yes | migration | [opentelemetry-instrumentation-jinja2](./opentelemetry-instrumentation-jinja2) | jinja2 >= 2.7, < 4.0 | No | development | [opentelemetry-instrumentation-kafka-python](./opentelemetry-instrumentation-kafka-python) | kafka-python >= 2.0, < 3.0,kafka-python-ng >= 2.0, < 3.0 | No | development | [opentelemetry-instrumentation-logging](./opentelemetry-instrumentation-logging) | logging | No | development diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst index 1a999c2a9d..8598f40e22 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -7,7 +7,8 @@ OpenTelemetry HTTPX Instrumentation :target: https://pypi.org/project/opentelemetry-instrumentation-httpx/ This library allows tracing HTTP requests made by the -`httpx `_ library. +`httpx `_ and +`httpx2 `_ libraries. Installation ------------ @@ -43,6 +44,27 @@ When using the instrumentor, all clients will automatically trace requests. asyncio.run(get(url)) +The same package also supports `httpx2 `_ using +the ``HTTPX2ClientInstrumentor``: + +.. code-block:: python + + import httpx2 + import asyncio + from opentelemetry.instrumentation.httpx import HTTPX2ClientInstrumentor + + url = "https://example.com" + HTTPX2ClientInstrumentor().instrument() + + with httpx2.Client() as client: + response = client.get(url) + + async def get(url): + async with httpx2.AsyncClient() as client: + response = await client.get(url) + + asyncio.run(get(url)) + Instrumenting single clients **************************** @@ -70,6 +92,17 @@ use the `HTTPXClientInstrumentor.instrument_client` method. asyncio.run(get(url)) +For ``httpx2`` clients, use ``HTTPX2ClientInstrumentor.instrument_client``: + +.. code-block:: python + + import httpx2 + from opentelemetry.instrumentation.httpx import HTTPX2ClientInstrumentor + + with httpx2.Client() as client: + HTTPX2ClientInstrumentor.instrument_client(client) + response = client.get("https://example.com") + Uninstrument ************ @@ -121,6 +154,20 @@ If you don't want to use the instrumentor class, you can use the transport class asyncio.run(get(url)) +For ``httpx2`` transports, use ``SyncOpenTelemetryTransportHttpx2`` and +``AsyncOpenTelemetryTransportHttpx2``: + +.. code-block:: python + + import httpx2 + from opentelemetry.instrumentation.httpx import SyncOpenTelemetryTransportHttpx2 + + transport = httpx2.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransportHttpx2(transport) + + with httpx2.Client(transport=telemetry_transport) as client: + response = client.get("https://example.com") + Request and response hooks *************************** diff --git a/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml b/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml index 3e3074c489..b8cee9ef2d 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-httpx/pyproject.toml @@ -33,12 +33,15 @@ dependencies = [ ] [project.optional-dependencies] -instruments = [ +instruments = [] +instruments-any = [ "httpx >= 0.18.0", + "httpx2 >= 2.0.0", ] [project.entry-points.opentelemetry_instrumentor] httpx = "opentelemetry.instrumentation.httpx:HTTPXClientInstrumentor" +httpx2 = "opentelemetry.instrumentation.httpx:HTTPX2ClientInstrumentor" [project.urls] Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-httpx" diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 1ac10ff1ec..9e040f34a7 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -307,11 +307,15 @@ async def async_response_hook(span, request, response): import typing from collections import defaultdict from functools import partial +from importlib import import_module from inspect import iscoroutinefunction from timeit import default_timer from types import TracebackType -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from wrapt import wrap_function_wrapper from opentelemetry.instrumentation._semconv import ( @@ -336,7 +340,10 @@ async def async_response_hook(span, request, response): _set_status, _StabilityMode, ) -from opentelemetry.instrumentation.httpx.package import _instruments +from opentelemetry.instrumentation.httpx.package import ( + _instruments_httpx, + _instruments_httpx2, +) from opentelemetry.instrumentation.httpx.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.utils import ( @@ -374,6 +381,31 @@ async def async_response_hook(span, request, response): _logger = logging.getLogger(__name__) + +class _HTTPXModule(typing.Protocol): + Request: type[httpx.Request] + Response: type[httpx.Response] + Headers: type[httpx.Headers] + URL: type[httpx.URL] + BaseTransport: type[httpx.BaseTransport] + AsyncBaseTransport: type[httpx.AsyncBaseTransport] + HTTPTransport: type[httpx.HTTPTransport] + AsyncHTTPTransport: type[httpx.AsyncHTTPTransport] + + +def _try_import(name: str) -> _HTTPXModule | None: + try: + return typing.cast(_HTTPXModule, import_module(name)) + except ImportError: + return None + + +_httpx_module = _try_import("httpx") +_httpx2_module = _try_import("httpx2") + +if _httpx_module is None and _httpx2_module is None: + raise ModuleNotFoundError("Either httpx or httpx2 must be installed") + RequestHook = typing.Callable[[Span, "RequestInfo"], None] ResponseHook = typing.Callable[[Span, "RequestInfo", "ResponseInfo"], None] AsyncRequestHook = typing.Callable[ @@ -407,12 +439,16 @@ def _get_default_span_name(method: str) -> str: return method -def _prepare_headers(headers: httpx.Headers | None) -> httpx.Headers: - return httpx.Headers(headers) +def _prepare_headers( + headers: httpx.Headers | None, module: _HTTPXModule +) -> httpx.Headers: + return typing.cast(httpx.Headers, module.Headers(headers)) def _extract_parameters( - args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any] + args: tuple[typing.Any, ...], + kwargs: dict[str, typing.Any], + module: _HTTPXModule, ) -> tuple[ bytes, httpx.URL | tuple[bytes, bytes, int | None, bytes], @@ -420,11 +456,11 @@ def _extract_parameters( httpx.SyncByteStream | httpx.AsyncByteStream | None, dict[str, typing.Any], ]: - if isinstance(args[0], httpx.Request): + if isinstance(args[0], module.Request): # In httpx >= 0.20.0, handle_request receives a Request object - request: httpx.Request = args[0] + request = typing.cast(httpx.Request, args[0]) method = request.method.encode() - url = httpx.URL(str(request.url)) + url = typing.cast(httpx.URL, module.URL(str(request.url))) headers = request.headers stream = request.stream extensions = request.extensions @@ -457,11 +493,11 @@ def _normalize_url( return str(url) -def _inject_propagation_headers(headers, args, kwargs): - _headers = _prepare_headers(headers) +def _inject_propagation_headers(headers, args, kwargs, module: _HTTPXModule): + _headers = _prepare_headers(headers, module) inject(_headers) - if isinstance(args[0], httpx.Request): - request: httpx.Request = args[0] + if isinstance(args[0], module.Request): + request = typing.cast(httpx.Request, args[0]) request.headers = _headers else: kwargs["headers"] = _headers.raw @@ -472,9 +508,10 @@ def _normalize_headers( | dict[str, list[str] | str] | list[tuple[bytes, bytes]] | None, + module: _HTTPXModule, ) -> dict[str, list[str]]: normalized_headers: defaultdict[str, list[str]] = defaultdict(list) - if isinstance(headers, httpx.Headers): + if isinstance(headers, module.Headers): for key in headers.keys(): normalized_headers[key.lower()].extend( headers.get_list(key, split_commas=True) @@ -496,6 +533,7 @@ def _normalize_headers( def _extract_response( response: httpx.Response | tuple[int, httpx.Headers, httpx.SyncByteStream, dict[str, typing.Any]], + module: _HTTPXModule, ) -> tuple[ int, httpx.Headers, @@ -503,12 +541,13 @@ def _extract_response( dict[str, typing.Any], str, ]: - if isinstance(response, httpx.Response): - status_code = response.status_code - headers = response.headers - stream = response.stream - extensions = response.extensions - http_version = response.http_version + if isinstance(response, module.Response): + http_response = typing.cast(httpx.Response, response) + status_code = http_response.status_code + headers = http_response.headers + stream = http_response.stream + extensions = http_response.extensions + http_version = http_response.http_version else: status_code, headers, stream, extensions = response http_version = extensions.get("http_version", b"HTTP/1.1").decode( @@ -524,11 +563,12 @@ def _apply_request_client_attributes_to_span( url: str | httpx.URL, method_original: str, semconv: _StabilityMode, + module: _HTTPXModule, headers: httpx.Headers | dict[str, list[str] | str] | None = None, captured_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, ): - url = httpx.URL(url) + url = typing.cast(httpx.URL, module.URL(url)) # http semconv transition: http.method -> http.request.method _set_http_method( span_attributes, @@ -550,7 +590,7 @@ def _apply_request_client_attributes_to_span( span_attributes.update( get_custom_header_attributes( - _normalize_headers(headers), + _normalize_headers(headers, module), captured_headers, sensitive_headers, normalise_request_header_name, @@ -585,6 +625,7 @@ def _apply_response_client_attributes_to_span( status_code: int, http_version: str, semconv: _StabilityMode, + module: _HTTPXModule, headers: httpx.Headers | dict[str, list[str] | str] | None = None, captured_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, @@ -602,7 +643,7 @@ def _apply_response_client_attributes_to_span( span.set_attributes( get_custom_header_attributes( - _normalize_headers(headers), + _normalize_headers(headers, module), captured_headers, sensitive_headers, normalise_response_header_name, @@ -651,7 +692,7 @@ def _apply_response_client_attributes_to_metrics( ) -class SyncOpenTelemetryTransport(httpx.BaseTransport): +class _SyncOpenTelemetryTransportBase: """Sync transport class that will trace all requests made with a client. Args: @@ -664,6 +705,8 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport): that is called right before the span ends """ + _module: _HTTPXModule = typing.cast(_HTTPXModule, httpx) + def __init__( self, transport: httpx.BaseTransport, @@ -721,7 +764,7 @@ def __init__( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS ) - def __enter__(self) -> SyncOpenTelemetryTransport: + def __enter__(self) -> "_SyncOpenTelemetryTransportBase": self._transport.__enter__() return self @@ -747,7 +790,7 @@ def handle_request( return self._transport.handle_request(*args, **kwargs) method, url, headers, stream, extensions = _extract_parameters( - args, kwargs + args, kwargs, self._module ) if self._excluded_urls and self._excluded_urls.url_disabled( @@ -766,6 +809,7 @@ def handle_request( url, method_original, self._sem_conv_opt_in_mode, + self._module, headers, self._captured_request_headers, self._sensitive_headers, @@ -780,7 +824,7 @@ def handle_request( if callable(self._request_hook): self._request_hook(span, request_info) - _inject_propagation_headers(headers, args, kwargs) + _inject_propagation_headers(headers, args, kwargs, self._module) start_time = default_timer() @@ -792,9 +836,9 @@ def handle_request( finally: elapsed_time = max(default_timer() - start_time, 0) - if isinstance(response, (httpx.Response, tuple)): + if isinstance(response, (self._module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response) + _extract_response(response, self._module) ) # Always apply response attributes to metrics @@ -813,6 +857,7 @@ def handle_request( status_code, http_version, self._sem_conv_opt_in_mode, + self._module, headers, self._captured_response_headers, self._sensitive_headers, @@ -864,7 +909,13 @@ def close(self) -> None: self._transport.close() -class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): +class SyncOpenTelemetryTransport( + _SyncOpenTelemetryTransportBase, httpx.BaseTransport +): + """Sync transport class that will trace all requests made with a client.""" + + +class _AsyncOpenTelemetryTransportBase: """Async transport class that will trace all requests made with a client. Args: @@ -877,6 +928,8 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): that is called right before the span ends """ + _module: _HTTPXModule = typing.cast(_HTTPXModule, httpx) + def __init__( self, transport: httpx.AsyncBaseTransport, @@ -936,7 +989,7 @@ def __init__( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS ) - async def __aenter__(self) -> "AsyncOpenTelemetryTransport": + async def __aenter__(self) -> "_AsyncOpenTelemetryTransportBase": await self._transport.__aenter__() return self @@ -960,7 +1013,7 @@ async def handle_async_request( return await self._transport.handle_async_request(*args, **kwargs) method, url, headers, stream, extensions = _extract_parameters( - args, kwargs + args, kwargs, self._module ) if self._excluded_urls and self._excluded_urls.url_disabled( @@ -979,6 +1032,7 @@ async def handle_async_request( url, method_original, self._sem_conv_opt_in_mode, + self._module, headers, self._captured_request_headers, self._sensitive_headers, @@ -993,7 +1047,7 @@ async def handle_async_request( if callable(self._request_hook): await self._request_hook(span, request_info) - _inject_propagation_headers(headers, args, kwargs) + _inject_propagation_headers(headers, args, kwargs, self._module) start_time = default_timer() @@ -1007,9 +1061,9 @@ async def handle_async_request( finally: elapsed_time = max(default_timer() - start_time, 0) - if isinstance(response, (httpx.Response, tuple)): + if isinstance(response, (self._module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response) + _extract_response(response, self._module) ) # Always apply response attributes to metrics @@ -1028,6 +1082,7 @@ async def handle_async_request( status_code, http_version, self._sem_conv_opt_in_mode, + self._module, headers, self._captured_response_headers, self._sensitive_headers, @@ -1081,15 +1136,42 @@ async def aclose(self) -> None: await self._transport.aclose() -class HTTPXClientInstrumentor(BaseInstrumentor): +class AsyncOpenTelemetryTransport( + _AsyncOpenTelemetryTransportBase, httpx.AsyncBaseTransport +): + """Async transport class that will trace all requests made with a client.""" + + +if _httpx2_module is not None: + + class SyncOpenTelemetryTransportHttpx2( + _SyncOpenTelemetryTransportBase, _httpx2_module.BaseTransport + ): + """Sync transport class that traces requests made with httpx2.""" + + _module = _httpx2_module + + class AsyncOpenTelemetryTransportHttpx2( + _AsyncOpenTelemetryTransportBase, _httpx2_module.AsyncBaseTransport + ): + """Async transport class that traces requests made with httpx2.""" + + _module = _httpx2_module + + +class _BaseHTTPXClientInstrumentor(BaseInstrumentor): # pylint: disable=protected-access """An instrumentor for httpx Client and AsyncClient See `BaseInstrumentor` """ + _module: typing.ClassVar[_HTTPXModule | None] + _module_name: typing.ClassVar[str] + _instrumentation_dependencies: typing.ClassVar[tuple[str, ...]] + def instrumentation_dependencies(self) -> typing.Collection[str]: - return _instruments + return self._instrumentation_dependencies # pylint: disable=too-many-locals def _instrument(self, **kwargs: typing.Any): @@ -1106,6 +1188,10 @@ def _instrument(self, **kwargs: typing.Any): ``async_request_hook``: Async ``request_hook`` for ``httpx.AsyncClient`` ``async_response_hook``: Async``response_hook`` for ``httpx.AsyncClient`` """ + module = self._module + if module is None: + raise ModuleNotFoundError(f"{self._module_name} must be installed") + tracer_provider = kwargs.get("tracer_provider") meter_provider = kwargs.get("meter_provider") request_hook = kwargs.get("request_hook") @@ -1170,10 +1256,11 @@ def _instrument(self, **kwargs: typing.Any): ) wrap_function_wrapper( - "httpx", + self._module_name, "HTTPTransport.handle_request", partial( self._handle_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1187,10 +1274,11 @@ def _instrument(self, **kwargs: typing.Any): ), ) wrap_function_wrapper( - "httpx", + self._module_name, "AsyncHTTPTransport.handle_async_request", partial( self._handle_async_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1205,8 +1293,11 @@ def _instrument(self, **kwargs: typing.Any): ) def _uninstrument(self, **kwargs: typing.Any): - unwrap(httpx.HTTPTransport, "handle_request") - unwrap(httpx.AsyncHTTPTransport, "handle_async_request") + module = self._module + if module is None: + raise ModuleNotFoundError(f"{self._module_name} must be installed") + unwrap(module.HTTPTransport, "handle_request") + unwrap(module.AsyncHTTPTransport, "handle_async_request") @staticmethod def _handle_request_wrapper( # pylint: disable=too-many-locals @@ -1214,6 +1305,8 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals instance: httpx.HTTPTransport, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], + *, + module: _HTTPXModule, tracer: Tracer, duration_histogram_old: Histogram, duration_histogram_new: Histogram, @@ -1229,7 +1322,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals return wrapped(*args, **kwargs) method, url, headers, stream, extensions = _extract_parameters( - args, kwargs + args, kwargs, module ) if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)): @@ -1246,6 +1339,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals url, method_original, sem_conv_opt_in_mode, + module, headers, captured_request_headers, sensitive_headers, @@ -1260,7 +1354,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals if callable(request_hook): request_hook(span, request_info) - _inject_propagation_headers(headers, args, kwargs) + _inject_propagation_headers(headers, args, kwargs, module) start_time = default_timer() @@ -1272,9 +1366,9 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals finally: elapsed_time = max(default_timer() - start_time, 0) - if isinstance(response, (httpx.Response, tuple)): + if isinstance(response, (module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response) + _extract_response(response, module) ) # Always apply response attributes to metrics @@ -1293,6 +1387,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals status_code, http_version, sem_conv_opt_in_mode, + module, headers, captured_response_headers, sensitive_headers, @@ -1345,6 +1440,8 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals instance: httpx.AsyncHTTPTransport, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], + *, + module: _HTTPXModule, tracer: Tracer, duration_histogram_old: Histogram, duration_histogram_new: Histogram, @@ -1360,7 +1457,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals return await wrapped(*args, **kwargs) method, url, headers, stream, extensions = _extract_parameters( - args, kwargs + args, kwargs, module ) if excluded_urls and excluded_urls.url_disabled(_normalize_url(url)): @@ -1377,6 +1474,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals url, method_original, sem_conv_opt_in_mode, + module, headers, captured_request_headers, sensitive_headers, @@ -1391,7 +1489,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals if callable(async_request_hook): await async_request_hook(span, request_info) - _inject_propagation_headers(headers, args, kwargs) + _inject_propagation_headers(headers, args, kwargs, module) start_time = default_timer() @@ -1403,9 +1501,9 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals finally: elapsed_time = max(default_timer() - start_time, 0) - if isinstance(response, (httpx.Response, tuple)): + if isinstance(response, (module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response) + _extract_response(response, module) ) # Always apply response attributes to metrics @@ -1424,6 +1522,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals status_code, http_version, sem_conv_opt_in_mode, + module, headers, captured_response_headers, sensitive_headers, @@ -1489,6 +1588,10 @@ def instrument_client( that is called right before the span ends """ + module = cls._module + if module is None: + raise ModuleNotFoundError(f"{cls._module_name} must be installed") + if getattr(client, "_is_instrumented_by_opentelemetry", False): _logger.warning( "Attempting to instrument Httpx client while already instrumented" @@ -1560,6 +1663,7 @@ def instrument_client( "handle_request", partial( cls._handle_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1579,6 +1683,7 @@ def instrument_client( "handle_request", partial( cls._handle_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1598,6 +1703,7 @@ def instrument_client( "handle_async_request", partial( cls._handle_async_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1617,6 +1723,7 @@ def instrument_client( "handle_async_request", partial( cls._handle_async_request_wrapper, + module=module, tracer=tracer, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, @@ -1648,3 +1755,19 @@ def uninstrument_client(client: httpx.Client | httpx.AsyncClient) -> None: for transport in client._mounts.values(): unwrap(transport, "handle_async_request") client._is_instrumented_by_opentelemetry = False + + +class HTTPXClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx Client and AsyncClient.""" + + _module = _httpx_module + _module_name = "httpx" + _instrumentation_dependencies = _instruments_httpx + + +class HTTPX2ClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx2 Client and AsyncClient.""" + + _module = _httpx2_module + _module_name = "httpx2" + _instrumentation_dependencies = _instruments_httpx2 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py index 60edd1c0b1..c135bbc1f8 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py @@ -1,8 +1,11 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +_instruments_httpx = ("httpx >= 0.18.0",) +_instruments_httpx2 = ("httpx2 >= 2.0.0",) -_instruments = ("httpx >= 0.18.0",) +_instruments = () +_instruments_any = (*_instruments_httpx, *_instruments_httpx2) _supports_metrics = True diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt1.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt1.txt new file mode 100644 index 0000000000..43ade166cc --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt1.txt @@ -0,0 +1,2 @@ +-r test-requirements-2.txt +wrapt==1.17.3 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt2.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt2.txt new file mode 100644 index 0000000000..e1147df891 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt2.txt @@ -0,0 +1,2 @@ +-r test-requirements-2.txt +wrapt==2.1.2 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2.txt new file mode 100644 index 0000000000..3d8b8fa290 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2.txt @@ -0,0 +1,5 @@ +httpx2==2.3.0 +pytest==7.4.4 +-e opentelemetry-instrumentation +-e util/opentelemetry-util-http +-e instrumentation/opentelemetry-instrumentation-httpx diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt1.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt1.txt new file mode 100644 index 0000000000..de8d56b394 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt1.txt @@ -0,0 +1,2 @@ +-r test-requirements-3.txt +wrapt==1.17.3 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt2.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt2.txt new file mode 100644 index 0000000000..3a175da179 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt2.txt @@ -0,0 +1,2 @@ +-r test-requirements-3.txt +wrapt==2.1.2 diff --git a/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3.txt b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3.txt new file mode 100644 index 0000000000..a02b9558c5 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3.txt @@ -0,0 +1,7 @@ +httpx==0.28.1 +httpx2==2.3.0 +pytest==7.4.4 +respx==0.22.0 +-e opentelemetry-instrumentation +-e util/opentelemetry-util-http +-e instrumentation/opentelemetry-instrumentation-httpx diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx2_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx2_integration.py new file mode 100644 index 0000000000..f16fed054c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx2_integration.py @@ -0,0 +1,279 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=arguments-differ,invalid-name,no-self-use,possibly-used-before-assignment + +import asyncio +import importlib.util +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_METHOD, + HTTP_STATUS_CODE, + HTTP_URL, +) +from opentelemetry.test.test_base import TestBase + +httpx2_installed = importlib.util.find_spec("httpx2") is not None + +if httpx2_installed: + import httpx2 + + from opentelemetry.instrumentation.httpx import ( + AsyncOpenTelemetryTransportHttpx2, + HTTPX2ClientInstrumentor, + SyncOpenTelemetryTransportHttpx2, + ) + + +class _TestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + body = b"Hello!" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + pass + + +@unittest.skipIf(not httpx2_installed, "httpx2 is not installed") +class TestHTTPX2Instrumentor(TestBase): + # NOTE: Unlike the httpx tests which use respx for mocking, these tests use + # a local server for global instrumentation and httpx2.MockTransport for + # client-level instrumentation. At the time of writing, respx does not yet + # have stable httpx2 support (see + # https://github.com/lundberg/respx/issues/316 and PR #317). + # pytest-httpx2 v1.0.0 exists but uses a fixture-based API that does not fit + # this unittest-style suite. MockTransport is built in to httpx2 and has zero + # external dependencies; the local server is used where we need to exercise + # HTTPTransport.handle_request directly. + # + # These tests are intentionally focused on proving that httpx2 reaches each + # public instrumentation path. The detailed span attributes, metrics, + # semantic convention modes, header capture, error paths, and suppression + # behavior are still covered by the original httpx suite because both + # instrumentors share the same wrapper and transport implementation. + + def setUp(self): + super().setUp() + self.env_patch = mock.patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: "default"}, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False + self.server = ThreadingHTTPServer(("127.0.0.1", 0), _TestHandler) + self.server_thread = threading.Thread( + target=self.server.serve_forever, + daemon=True, + ) + self.server_thread.start() + host, port = self.server.server_address + self.url = f"http://{host}:{port}/status/200" + + def tearDown(self): + HTTPX2ClientInstrumentor().uninstrument() + self.server.shutdown() + self.server.server_close() + self.server_thread.join(timeout=5) + self.env_patch.stop() + super().tearDown() + + def _mock_transport(self, status_code=200): + def handler(request): + return httpx2.Response( + status_code, + text="Hello!", + extensions={"http_version": b"HTTP/1.1"}, + ) + + return httpx2.MockTransport(handler) + + def _assert_basic_span(self, url=None): + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + span_attributes = dict(span.attributes) + self.assertEqual(span_attributes[HTTP_METHOD], "GET") + self.assertEqual(span_attributes[HTTP_URL], url or self.url) + self.assertEqual(span_attributes[HTTP_STATUS_CODE], 200) + return span + + def test_sync_global_instrumentation(self): + HTTPX2ClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + with httpx2.Client() as client: + response = client.get(self.url) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span() + + def test_async_global_instrumentation(self): + async def do_request(): + HTTPX2ClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + async with httpx2.AsyncClient() as client: + return await client.get(self.url) + + response = asyncio.run(do_request()) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span() + + def test_sync_instrument_client(self): + url = "http://mock/status/200" + client = httpx2.Client(transport=self._mock_transport()) + HTTPX2ClientInstrumentor.instrument_client( + client, + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + response = client.get(url) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span(url) + HTTPX2ClientInstrumentor.uninstrument_client(client) + client.close() + + def test_async_instrument_client(self): + async def do_request(): + url = "http://mock/status/200" + client = httpx2.AsyncClient( + transport=httpx2.MockTransport( + lambda request: httpx2.Response( + 200, + text="Hello!", + extensions={"http_version": b"HTTP/1.1"}, + ) + ) + ) + HTTPX2ClientInstrumentor.instrument_client( + client, + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + response = await client.get(url) + HTTPX2ClientInstrumentor.uninstrument_client(client) + await client.aclose() + return url, response + + url, response = asyncio.run(do_request()) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span(url) + + def test_uninstrument_client(self): + url = "http://mock/status/200" + client = httpx2.Client(transport=self._mock_transport()) + HTTPX2ClientInstrumentor.instrument_client( + client, + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + HTTPX2ClientInstrumentor.uninstrument_client(client) + + response = client.get(url) + + self.assertEqual(response.text, "Hello!") + self.assertEqual(self.memory_exporter.get_finished_spans(), ()) + client.close() + + def test_sync_transport_wrapper(self): + url = "http://mock/status/200" + transport = SyncOpenTelemetryTransportHttpx2( + self._mock_transport(), + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + with httpx2.Client(transport=transport) as client: + response = client.get(url) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span(url) + + def test_async_transport_wrapper(self): + async def do_request(): + url = "http://mock/status/200" + transport = AsyncOpenTelemetryTransportHttpx2( + httpx2.MockTransport( + lambda request: httpx2.Response( + 200, + text="Hello!", + extensions={"http_version": b"HTTP/1.1"}, + ) + ), + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + async with httpx2.AsyncClient(transport=transport) as client: + return url, await client.get(url) + + url, response = asyncio.run(do_request()) + + self.assertEqual(response.text, "Hello!") + self._assert_basic_span(url) + + def test_hooks(self): + url = "http://mock/status/200" + + def request_hook(span, request): + span.set_attribute("request_hook", request.method.decode()) + + def response_hook(span, request, response): + span.set_attribute("response_hook", response.status_code) + + client = httpx2.Client(transport=self._mock_transport()) + HTTPX2ClientInstrumentor.instrument_client( + client, + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + request_hook=request_hook, + response_hook=response_hook, + ) + + response = client.get(url) + + self.assertEqual(response.text, "Hello!") + span = self._assert_basic_span(url) + self.assertEqual(span.attributes["request_hook"], "GET") + self.assertEqual(span.attributes["response_hook"], 200) + HTTPX2ClientInstrumentor.uninstrument_client(client) + client.close() + + def test_excluded_urls(self): + self.env_patch.stop() + with mock.patch.dict( + "os.environ", + {"OTEL_PYTHON_HTTPX_EXCLUDED_URLS": self.url}, + ): + HTTPX2ClientInstrumentor().instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + with httpx2.Client() as client: + response = client.get(self.url) + self.env_patch.start() + + self.assertEqual(response.text, "Hello!") + self.assertEqual(self.memory_exporter.get_finished_spans(), ()) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 8fd65b4c61..1ee2cfa164 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -101,6 +101,10 @@ "library": "httpx >= 0.18.0", "instrumentation": "opentelemetry-instrumentation-httpx==0.65b0.dev", }, + { + "library": "httpx2 >= 2.0.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.64b0.dev", + }, { "library": "jinja2 >= 2.7, < 4.0", "instrumentation": "opentelemetry-instrumentation-jinja2==0.65b0.dev", diff --git a/pyproject.toml b/pyproject.toml index acccc8e2d7..4c90657e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "opentelemetry-instrumentation-fastapi[instruments]", "opentelemetry-instrumentation-flask[instruments]", "opentelemetry-instrumentation-grpc[instruments]", - "opentelemetry-instrumentation-httpx[instruments]", + "opentelemetry-instrumentation-httpx[instruments-any]", "opentelemetry-instrumentation-jinja2[instruments]", "opentelemetry-instrumentation-kafka-python[instruments]", "opentelemetry-instrumentation-kafka-python[instruments-any]", diff --git a/tox.ini b/tox.ini index 99b6c241c5..c3c71b107a 100644 --- a/tox.ini +++ b/tox.ini @@ -359,7 +359,11 @@ envlist = ; below mean these dependencies are being used: ; 0: httpx>=0.18.0,<0.19.0 respx~=0.17.0 ; 1: httpx>=0.19.0 respx~=0.20.1 + ; 2: httpx2>=2.0.0 + ; 3: httpx>=0.19.0 respx~=0.20.1 httpx2>=2.0.0 py3{10,11,12}-test-instrumentation-httpx-{0,1}-{wrapt1,wrapt2} + py3{10,11,12,13,14}-test-instrumentation-httpx-2-{wrapt1,wrapt2} + py3{10,11,12,13,14}-test-instrumentation-httpx-3-{wrapt1,wrapt2} py3{13,14}-test-instrumentation-httpx-1-{wrapt1,wrapt2} pypy3-test-instrumentation-httpx-{0,1}-wrapt2 lint-instrumentation-httpx @@ -736,6 +740,10 @@ deps = httpx-0-wrapt2: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-0-wrapt2.txt httpx-1-wrapt1: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-1-wrapt1.txt httpx-1-wrapt2: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-1-wrapt2.txt + httpx-2-wrapt1: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt1.txt + httpx-2-wrapt2: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-2-wrapt2.txt + httpx-3-wrapt1: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt1.txt + httpx-3-wrapt2: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-3-wrapt2.txt lint-instrumentation-httpx: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/test-requirements-1-wrapt2.txt # packages that are released individually should provide a test-requirements.txt with the lowest version of OTel API @@ -992,7 +1000,9 @@ commands = test-instrumentation-wsgi: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi/tests {posargs} lint-instrumentation-wsgi: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-wsgi" - test-instrumentation-httpx: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/tests {posargs} + test-instrumentation-httpx-{0,1}: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/tests {posargs} + test-instrumentation-httpx-2: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx2_integration.py {posargs} + test-instrumentation-httpx-3: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx/tests {posargs} lint-instrumentation-httpx: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-httpx" test-instrumentation-asyncio: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-asyncio/tests {posargs} diff --git a/uv.lock b/uv.lock index e6d89beb73..adc4215353 100644 --- a/uv.lock +++ b/uv.lock @@ -1893,6 +1893,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/9b/2b1d1833a58236d1f6ee755e027a3917da0db59cc9708554cefc440ee8b6/httpcore2-2.4.0.tar.gz", hash = "sha256:3093a8ab8980d9f910b9cb4351df9186a0ad2350a6284a9107ac9a362a584422", size = 64618, upload-time = "2026-06-11T06:35:53.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/72/4fdf2306143a92a471fad9f3655aa542d43aa9188a7c9534e82c9aecf837/httpcore2-2.4.0-py3-none-any.whl", hash = "sha256:5218779da5d6e3c2013ac706121abfb3815d450e0613495c0de50264dce58242", size = 80151, upload-time = "2026-06-11T06:35:50.89Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1917,6 +1930,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "httpx2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/60/b43ced4ccf26e95b396dbf67051d3e5042b645917d4da0469dd82a3bdd4f/httpx2-2.4.0.tar.gz", hash = "sha256:32e0734b61eb0824b3f56a9e98d6d92d381a3ef12c0045aa917ee63df6c411ef", size = 81691, upload-time = "2026-06-11T06:35:54.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/45/82bc57c3d9c3314f663b67cc057f1c017a6450685dde513f4f8db5cf431f/httpx2-2.4.0-py3-none-any.whl", hash = "sha256:425acd99297829599decf6701386dd84db3542597d36d3e2e4def930ecd57fd9", size = 74941, upload-time = "2026-06-11T06:35:52.235Z" }, +] + [[package]] name = "hupper" version = "1.12.1" @@ -3496,20 +3525,22 @@ dependencies = [ ] [package.optional-dependencies] -instruments = [ +instruments-any = [ { name = "httpx" }, + { name = "httpx2" }, ] [package.metadata] requires-dist = [ - { name = "httpx", marker = "extra == 'instruments'", specifier = ">=0.18.0" }, + { name = "httpx", marker = "extra == 'instruments-any'", specifier = ">=0.18.0" }, + { name = "httpx2", marker = "extra == 'instruments-any'", specifier = ">=2.0.0" }, { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, { name = "opentelemetry-util-http", editable = "util/opentelemetry-util-http" }, { name = "wrapt", specifier = ">=1.0.0,<3.0.0" }, ] -provides-extras = ["instruments"] +provides-extras = ["instruments", "instruments-any"] [[package]] name = "opentelemetry-instrumentation-jinja2" @@ -4325,7 +4356,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-fastapi", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-flask", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-grpc", extra = ["instruments"] }, - { name = "opentelemetry-instrumentation-httpx", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-httpx", extra = ["instruments-any"] }, { name = "opentelemetry-instrumentation-jinja2", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-kafka-python", extra = ["instruments-any"] }, { name = "opentelemetry-instrumentation-logging" }, @@ -4398,7 +4429,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-fastapi", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-flask", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-flask" }, { name = "opentelemetry-instrumentation-grpc", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-grpc" }, - { name = "opentelemetry-instrumentation-httpx", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-httpx", extras = ["instruments-any"], editable = "instrumentation/opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-instrumentation-jinja2", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-jinja2" }, { name = "opentelemetry-instrumentation-kafka-python", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-kafka-python" }, { name = "opentelemetry-instrumentation-kafka-python", extras = ["instruments-any"], editable = "instrumentation/opentelemetry-instrumentation-kafka-python" }, @@ -6332,6 +6363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/98/36187601a15e3d37e9bfcf0e0e1055532b39d044353b06861c3a519737a9/translationstring-1.4-py2.py3-none-any.whl", hash = "sha256:5f4dc4d939573db851c8d840551e1a0fb27b946afe3b95aafc22577eed2d6262", size = 15028, upload-time = "2020-07-09T11:58:30.995Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 61ac11797b2dc0e09d10b2ca97dab72b0ccaffc8 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 21:06:26 -0400 Subject: [PATCH 02/10] small readme edit --- .../opentelemetry-instrumentation-httpx/README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/README.rst b/instrumentation/opentelemetry-instrumentation-httpx/README.rst index 8598f40e22..e3a9591aa3 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -10,6 +10,10 @@ This library allows tracing HTTP requests made by the `httpx `_ and `httpx2 `_ libraries. +If both libraries are installed, use ``HTTPXClientInstrumentor`` for +``httpx`` clients and ``HTTPX2ClientInstrumentor`` for ``httpx2`` clients. +The instrumentors can be enabled independently. + Installation ------------ From 5530f5453497bfc5585711dd60d51878cb9189d5 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 21:08:10 -0400 Subject: [PATCH 03/10] Add docstring for httpx2 --- .../instrumentation/httpx/__init__.py | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 9e040f34a7..eaa877fcbb 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -28,6 +28,26 @@ async def get(url): asyncio.run(get(url)) +When instrumenting ``httpx2`` clients, use ``HTTPX2ClientInstrumentor``: + +.. code-block:: python + + import httpx2 + import asyncio + from opentelemetry.instrumentation.httpx import HTTPX2ClientInstrumentor + + url = "https://example.com" + HTTPX2ClientInstrumentor().instrument() + + with httpx2.Client() as client: + response = client.get(url) + + async def get(url): + async with httpx2.AsyncClient() as client: + response = await client.get(url) + + asyncio.run(get(url)) + Instrumenting single clients **************************** @@ -54,6 +74,17 @@ async def get(url): asyncio.run(get(url)) +For ``httpx2`` clients, use ``HTTPX2ClientInstrumentor.instrument_client``: + +.. code-block:: python + + import httpx2 + from opentelemetry.instrumentation.httpx import HTTPX2ClientInstrumentor + + with httpx2.Client() as client: + HTTPX2ClientInstrumentor.instrument_client(client) + response = client.get("https://example.com") + Uninstrument ************ @@ -105,6 +136,20 @@ async def get(url): asyncio.run(get(url)) +For ``httpx2`` transports, use ``SyncOpenTelemetryTransportHttpx2`` and +``AsyncOpenTelemetryTransportHttpx2``: + +.. code-block:: python + + import httpx2 + from opentelemetry.instrumentation.httpx import SyncOpenTelemetryTransportHttpx2 + + transport = httpx2.HTTPTransport() + telemetry_transport = SyncOpenTelemetryTransportHttpx2(transport) + + with httpx2.Client(transport=telemetry_transport) as client: + response = client.get("https://example.com") + Request and response hooks *************************** @@ -1161,7 +1206,7 @@ class AsyncOpenTelemetryTransportHttpx2( class _BaseHTTPXClientInstrumentor(BaseInstrumentor): # pylint: disable=protected-access - """An instrumentor for httpx Client and AsyncClient + """Base instrumentor for httpx-compatible Client and AsyncClient classes. See `BaseInstrumentor` """ @@ -1175,7 +1220,7 @@ def instrumentation_dependencies(self) -> typing.Collection[str]: # pylint: disable=too-many-locals def _instrument(self, **kwargs: typing.Any): - """Instruments httpx Client and AsyncClient + """Instrument the configured httpx-compatible Client and AsyncClient. Args: **kwargs: Optional arguments @@ -1576,10 +1621,10 @@ def instrument_client( request_hook: RequestHook | AsyncRequestHook | None = None, response_hook: ResponseHook | AsyncResponseHook | None = None, ) -> None: - """Instrument httpx Client or AsyncClient + """Instrument an httpx-compatible Client or AsyncClient. Args: - client: The httpx Client or AsyncClient instance + client: The Client or AsyncClient instance tracer_provider: A TracerProvider, defaults to global meter_provider: A MeterProvider, defaults to global request_hook: A hook that receives the span and request that is called @@ -1743,7 +1788,7 @@ def uninstrument_client(client: httpx.Client | httpx.AsyncClient) -> None: """Disables instrumentation for the given client instance Args: - client: The httpx Client or AsyncClient instance + client: The Client or AsyncClient instance """ if hasattr(client._transport, "handle_request"): unwrap(client._transport, "handle_request") From 44520ed6940d4f78dbb46a1ed697d0e2a9a0bc7f Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 21:19:07 -0400 Subject: [PATCH 04/10] doc updates --- .../instrumentation/httpx/__init__.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index eaa877fcbb..2f564ebf54 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -1206,7 +1206,7 @@ class AsyncOpenTelemetryTransportHttpx2( class _BaseHTTPXClientInstrumentor(BaseInstrumentor): # pylint: disable=protected-access - """Base instrumentor for httpx-compatible Client and AsyncClient classes. + """Base instrumentor for httpx API-compatible clients. See `BaseInstrumentor` """ @@ -1220,18 +1220,22 @@ def instrumentation_dependencies(self) -> typing.Collection[str]: # pylint: disable=too-many-locals def _instrument(self, **kwargs: typing.Any): - """Instrument the configured httpx-compatible Client and AsyncClient. + """Instrument the configured httpx API-compatible clients. Args: **kwargs: Optional arguments ``tracer_provider``: a TracerProvider, defaults to global ``meter_provider``: a MeterProvider, defaults to global - ``request_hook``: A ``httpx.Client`` hook that receives the span and request - that is called right after the span is created - ``response_hook``: A ``httpx.Client`` hook that receives the span, request, - and response that is called right before the span ends - ``async_request_hook``: Async ``request_hook`` for ``httpx.AsyncClient`` - ``async_response_hook``: Async``response_hook`` for ``httpx.AsyncClient`` + ``request_hook``: A ``httpx.Client`` or ``httpx2.Client`` hook + that receives the span and request that is called right + after the span is created. + ``response_hook``: A ``httpx.Client`` or ``httpx2.Client`` hook + that receives the span, request, and response that is + called right before the span ends. + ``async_request_hook``: Async request hook for + ``httpx.AsyncClient`` or ``httpx2.AsyncClient``. + ``async_response_hook``: Async response hook for + ``httpx.AsyncClient`` or ``httpx2.AsyncClient``. """ module = self._module if module is None: @@ -1621,7 +1625,7 @@ def instrument_client( request_hook: RequestHook | AsyncRequestHook | None = None, response_hook: ResponseHook | AsyncResponseHook | None = None, ) -> None: - """Instrument an httpx-compatible Client or AsyncClient. + """Instrument an httpx API-compatible Client or AsyncClient. Args: client: The Client or AsyncClient instance From 39844e08723f9304a8bb21782bcaac02ba3437b4 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 22:28:06 -0400 Subject: [PATCH 05/10] ensure that if only httpx2 is available, that the httpx instrumentor isn't backed by httpx2 classes --- .../instrumentation/httpx/__init__.py | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 2f564ebf54..9dd90a2af0 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -357,10 +357,6 @@ async def async_response_hook(span, request, response): from timeit import default_timer from types import TracebackType -try: - import httpx -except ImportError: - import httpx2 as httpx from wrapt import wrap_function_wrapper from opentelemetry.instrumentation._semconv import ( @@ -424,6 +420,12 @@ async def async_response_hook(span, request, response): sanitize_method, ) +if typing.TYPE_CHECKING: + try: + import httpx + except ImportError: + import httpx2 as httpx + _logger = logging.getLogger(__name__) @@ -487,7 +489,7 @@ def _get_default_span_name(method: str) -> str: def _prepare_headers( headers: httpx.Headers | None, module: _HTTPXModule ) -> httpx.Headers: - return typing.cast(httpx.Headers, module.Headers(headers)) + return typing.cast("httpx.Headers", module.Headers(headers)) def _extract_parameters( @@ -503,9 +505,9 @@ def _extract_parameters( ]: if isinstance(args[0], module.Request): # In httpx >= 0.20.0, handle_request receives a Request object - request = typing.cast(httpx.Request, args[0]) + request = typing.cast("httpx.Request", args[0]) method = request.method.encode() - url = typing.cast(httpx.URL, module.URL(str(request.url))) + url = typing.cast("httpx.URL", module.URL(str(request.url))) headers = request.headers stream = request.stream extensions = request.extensions @@ -542,7 +544,7 @@ def _inject_propagation_headers(headers, args, kwargs, module: _HTTPXModule): _headers = _prepare_headers(headers, module) inject(_headers) if isinstance(args[0], module.Request): - request = typing.cast(httpx.Request, args[0]) + request = typing.cast("httpx.Request", args[0]) request.headers = _headers else: kwargs["headers"] = _headers.raw @@ -587,7 +589,7 @@ def _extract_response( str, ]: if isinstance(response, module.Response): - http_response = typing.cast(httpx.Response, response) + http_response = typing.cast("httpx.Response", response) status_code = http_response.status_code headers = http_response.headers stream = http_response.stream @@ -613,7 +615,7 @@ def _apply_request_client_attributes_to_span( captured_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, ): - url = typing.cast(httpx.URL, module.URL(url)) + url = typing.cast("httpx.URL", module.URL(url)) # http semconv transition: http.method -> http.request.method _set_http_method( span_attributes, @@ -750,7 +752,7 @@ class _SyncOpenTelemetryTransportBase: that is called right before the span ends """ - _module: _HTTPXModule = typing.cast(_HTTPXModule, httpx) + _module: _HTTPXModule def __init__( self, @@ -954,10 +956,14 @@ def close(self) -> None: self._transport.close() -class SyncOpenTelemetryTransport( - _SyncOpenTelemetryTransportBase, httpx.BaseTransport -): - """Sync transport class that will trace all requests made with a client.""" +if _httpx_module is not None: + + class SyncOpenTelemetryTransport( + _SyncOpenTelemetryTransportBase, _httpx_module.BaseTransport + ): + """Sync transport class that traces requests made with httpx.""" + + _module = _httpx_module class _AsyncOpenTelemetryTransportBase: @@ -973,7 +979,7 @@ class _AsyncOpenTelemetryTransportBase: that is called right before the span ends """ - _module: _HTTPXModule = typing.cast(_HTTPXModule, httpx) + _module: _HTTPXModule def __init__( self, @@ -1181,10 +1187,14 @@ async def aclose(self) -> None: await self._transport.aclose() -class AsyncOpenTelemetryTransport( - _AsyncOpenTelemetryTransportBase, httpx.AsyncBaseTransport -): - """Async transport class that will trace all requests made with a client.""" +if _httpx_module is not None: + + class AsyncOpenTelemetryTransport( + _AsyncOpenTelemetryTransportBase, _httpx_module.AsyncBaseTransport + ): + """Async transport class that traces requests made with httpx.""" + + _module = _httpx_module if _httpx2_module is not None: From 448ac993966d6f98d20b27c43ccdc8294b192a34 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Mon, 22 Jun 2026 23:16:18 -0400 Subject: [PATCH 06/10] feedback: httpx protocol to type checking block --- .../instrumentation/httpx/__init__.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 9dd90a2af0..6e0bb7cdfa 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -426,23 +426,23 @@ async def async_response_hook(span, request, response): except ImportError: import httpx2 as httpx -_logger = logging.getLogger(__name__) + class _HTTPXModule(typing.Protocol): + Request: type[httpx.Request] + Response: type[httpx.Response] + Headers: type[httpx.Headers] + URL: type[httpx.URL] + BaseTransport: type[httpx.BaseTransport] + AsyncBaseTransport: type[httpx.AsyncBaseTransport] + HTTPTransport: type[httpx.HTTPTransport] + AsyncHTTPTransport: type[httpx.AsyncHTTPTransport] -class _HTTPXModule(typing.Protocol): - Request: type[httpx.Request] - Response: type[httpx.Response] - Headers: type[httpx.Headers] - URL: type[httpx.URL] - BaseTransport: type[httpx.BaseTransport] - AsyncBaseTransport: type[httpx.AsyncBaseTransport] - HTTPTransport: type[httpx.HTTPTransport] - AsyncHTTPTransport: type[httpx.AsyncHTTPTransport] +_logger = logging.getLogger(__name__) def _try_import(name: str) -> _HTTPXModule | None: try: - return typing.cast(_HTTPXModule, import_module(name)) + return typing.cast("_HTTPXModule", import_module(name)) except ImportError: return None From 8ff8669386b193c7fd51a5f27a3fd6c4c4007cc7 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Tue, 23 Jun 2026 08:26:43 -0400 Subject: [PATCH 07/10] feedback: remove top level fail, fix annotations --- .../src/opentelemetry/instrumentation/httpx/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 6e0bb7cdfa..f26c5d0fe9 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -450,9 +450,6 @@ def _try_import(name: str) -> _HTTPXModule | None: _httpx_module = _try_import("httpx") _httpx2_module = _try_import("httpx2") -if _httpx_module is None and _httpx2_module is None: - raise ModuleNotFoundError("Either httpx or httpx2 must be installed") - RequestHook = typing.Callable[[Span, "RequestInfo"], None] ResponseHook = typing.Callable[[Span, "RequestInfo", "ResponseInfo"], None] AsyncRequestHook = typing.Callable[ @@ -811,7 +808,7 @@ def __init__( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS ) - def __enter__(self) -> "_SyncOpenTelemetryTransportBase": + def __enter__(self) -> _SyncOpenTelemetryTransportBase: self._transport.__enter__() return self @@ -1040,7 +1037,7 @@ def __init__( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS ) - async def __aenter__(self) -> "_AsyncOpenTelemetryTransportBase": + async def __aenter__(self) -> _AsyncOpenTelemetryTransportBase: await self._transport.__aenter__() return self From 6df45202eb6eb761661ab2ac040eacef8406db2e Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Tue, 23 Jun 2026 09:02:15 -0400 Subject: [PATCH 08/10] feedback: move class definitions to the same conditions, fix docgen and genned workflows --- .github/workflows/core_contrib_test.yml | 4 +- docs/instrumentation/httpx/httpx.rst | 1 + docs/nitpick-exceptions.ini | 3 + .../instrumentation/httpx/__init__.py | 89 +++++++++---------- 4 files changed, 48 insertions(+), 49 deletions(-) diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index dd66ecf055..8cd1fbce0c 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -3088,7 +3088,7 @@ jobs: run: tox -e py310-test-instrumentation-httpx-2-wrapt2 -- -ra py310-test-instrumentation-httpx-3-wrapt1: - name: instrumentation-httpx-3-wrapt1 + name: instrumentation-httpx-3-wrapt1 runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -3118,7 +3118,7 @@ jobs: run: tox -e py310-test-instrumentation-httpx-3-wrapt1 -- -ra py310-test-instrumentation-httpx-3-wrapt2: - name: instrumentation-httpx-3-wrapt2 + name: instrumentation-httpx-3-wrapt2 runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/docs/instrumentation/httpx/httpx.rst b/docs/instrumentation/httpx/httpx.rst index f816539f5b..964156ab08 100644 --- a/docs/instrumentation/httpx/httpx.rst +++ b/docs/instrumentation/httpx/httpx.rst @@ -8,3 +8,4 @@ API :members: :undoc-members: :show-inheritance: + :inherited-members: diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index 5c05f44b53..2c069ab847 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -50,6 +50,9 @@ py-class= fastapi.applications.FastAPI starlette.applications.Starlette _contextvars.Token + opentelemetry.instrumentation.httpx._SyncOpenTelemetryTransportBase + opentelemetry.instrumentation.httpx._AsyncOpenTelemetryTransportBase + opentelemetry.instrumentation.httpx._BaseHTTPXClientInstrumentor any= ; API diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index f26c5d0fe9..27ebf82351 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -953,16 +953,6 @@ def close(self) -> None: self._transport.close() -if _httpx_module is not None: - - class SyncOpenTelemetryTransport( - _SyncOpenTelemetryTransportBase, _httpx_module.BaseTransport - ): - """Sync transport class that traces requests made with httpx.""" - - _module = _httpx_module - - class _AsyncOpenTelemetryTransportBase: """Async transport class that will trace all requests made with a client. @@ -1184,33 +1174,6 @@ async def aclose(self) -> None: await self._transport.aclose() -if _httpx_module is not None: - - class AsyncOpenTelemetryTransport( - _AsyncOpenTelemetryTransportBase, _httpx_module.AsyncBaseTransport - ): - """Async transport class that traces requests made with httpx.""" - - _module = _httpx_module - - -if _httpx2_module is not None: - - class SyncOpenTelemetryTransportHttpx2( - _SyncOpenTelemetryTransportBase, _httpx2_module.BaseTransport - ): - """Sync transport class that traces requests made with httpx2.""" - - _module = _httpx2_module - - class AsyncOpenTelemetryTransportHttpx2( - _AsyncOpenTelemetryTransportBase, _httpx2_module.AsyncBaseTransport - ): - """Async transport class that traces requests made with httpx2.""" - - _module = _httpx2_module - - class _BaseHTTPXClientInstrumentor(BaseInstrumentor): # pylint: disable=protected-access """Base instrumentor for httpx API-compatible clients. @@ -1813,17 +1776,49 @@ def uninstrument_client(client: httpx.Client | httpx.AsyncClient) -> None: client._is_instrumented_by_opentelemetry = False -class HTTPXClientInstrumentor(_BaseHTTPXClientInstrumentor): - """An instrumentor for httpx Client and AsyncClient.""" +if _httpx_module is not None: + + class SyncOpenTelemetryTransport( + _SyncOpenTelemetryTransportBase, _httpx_module.BaseTransport + ): + """Sync transport class that traces requests made with httpx.""" + + _module = _httpx_module + + class AsyncOpenTelemetryTransport( + _AsyncOpenTelemetryTransportBase, _httpx_module.AsyncBaseTransport + ): + """Async transport class that traces requests made with httpx.""" - _module = _httpx_module - _module_name = "httpx" - _instrumentation_dependencies = _instruments_httpx + _module = _httpx_module + + class HTTPXClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx Client and AsyncClient.""" + _module = _httpx_module + _module_name = "httpx" + _instrumentation_dependencies = _instruments_httpx -class HTTPX2ClientInstrumentor(_BaseHTTPXClientInstrumentor): - """An instrumentor for httpx2 Client and AsyncClient.""" - _module = _httpx2_module - _module_name = "httpx2" - _instrumentation_dependencies = _instruments_httpx2 +if _httpx2_module is not None: + + class SyncOpenTelemetryTransportHttpx2( + _SyncOpenTelemetryTransportBase, _httpx2_module.BaseTransport + ): + """Sync transport class that traces requests made with httpx2.""" + + _module = _httpx2_module + + class AsyncOpenTelemetryTransportHttpx2( + _AsyncOpenTelemetryTransportBase, _httpx2_module.AsyncBaseTransport + ): + """Async transport class that traces requests made with httpx2.""" + + _module = _httpx2_module + + class HTTPX2ClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx2 Client and AsyncClient.""" + + _module = _httpx2_module + _module_name = "httpx2" + _instrumentation_dependencies = _instruments_httpx2 From 6237557f37ad8b964ed3f29646d3cd51eaf0cd21 Mon Sep 17 00:00:00 2001 From: gloshkajian Date: Tue, 23 Jun 2026 16:16:22 -0400 Subject: [PATCH 09/10] feedback: pull out the client instrumentor --- .../instrumentation/httpx/__init__.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 27ebf82351..a18be5b607 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -1792,12 +1792,13 @@ class AsyncOpenTelemetryTransport( _module = _httpx_module - class HTTPXClientInstrumentor(_BaseHTTPXClientInstrumentor): - """An instrumentor for httpx Client and AsyncClient.""" - _module = _httpx_module - _module_name = "httpx" - _instrumentation_dependencies = _instruments_httpx +class HTTPXClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx Client and AsyncClient.""" + + _module = _httpx_module + _module_name = "httpx" + _instrumentation_dependencies = _instruments_httpx if _httpx2_module is not None: @@ -1816,9 +1817,10 @@ class AsyncOpenTelemetryTransportHttpx2( _module = _httpx2_module - class HTTPX2ClientInstrumentor(_BaseHTTPXClientInstrumentor): - """An instrumentor for httpx2 Client and AsyncClient.""" - _module = _httpx2_module - _module_name = "httpx2" - _instrumentation_dependencies = _instruments_httpx2 +class HTTPX2ClientInstrumentor(_BaseHTTPXClientInstrumentor): + """An instrumentor for httpx2 Client and AsyncClient.""" + + _module = _httpx2_module + _module_name = "httpx2" + _instrumentation_dependencies = _instruments_httpx2 From 89524ab003d1e456df988ba27af3f99f6335646c Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 15 Jul 2026 10:39:57 +0200 Subject: [PATCH 10/10] tox -e generate --- .../src/opentelemetry/instrumentation/bootstrap_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 1ee2cfa164..ac9adc84a3 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -103,7 +103,7 @@ }, { "library": "httpx2 >= 2.0.0", - "instrumentation": "opentelemetry-instrumentation-httpx==0.64b0.dev", + "instrumentation": "opentelemetry-instrumentation-httpx==0.65b0.dev", }, { "library": "jinja2 >= 2.7, < 4.0",