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 21d1a088ca..be80dd5e94 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -3087,6 +3087,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 09ca381581..f27399a863 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10436,6 +10436,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/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/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..e3a9591aa3 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/README.rst +++ b/instrumentation/opentelemetry-instrumentation-httpx/README.rst @@ -7,7 +7,12 @@ 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. + +If both libraries are installed, use ``HTTPXClientInstrumentor`` for +``httpx`` clients and ``HTTPX2ClientInstrumentor`` for ``httpx2`` clients. +The instrumentors can be enabled independently. Installation ------------ @@ -43,6 +48,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 +96,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 +158,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..a18be5b607 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 *************************** @@ -307,11 +352,11 @@ 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 from wrapt import wrap_function_wrapper from opentelemetry.instrumentation._semconv import ( @@ -336,7 +381,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 ( @@ -372,8 +420,36 @@ async def async_response_hook(span, request, response): sanitize_method, ) +if typing.TYPE_CHECKING: + try: + import httpx + except ImportError: + import httpx2 as httpx + + 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)) + except ImportError: + return None + + +_httpx_module = _try_import("httpx") +_httpx2_module = _try_import("httpx2") + RequestHook = typing.Callable[[Span, "RequestInfo"], None] ResponseHook = typing.Callable[[Span, "RequestInfo", "ResponseInfo"], None] AsyncRequestHook = typing.Callable[ @@ -407,12 +483,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 +500,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 +537,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 +552,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 +577,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 +585,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 +607,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 +634,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 +669,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 +687,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 +736,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 +749,8 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport): that is called right before the span ends """ + _module: _HTTPXModule + def __init__( self, transport: httpx.BaseTransport, @@ -721,7 +808,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 +834,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 +853,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 +868,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 +880,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 +901,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 +953,7 @@ def close(self) -> None: self._transport.close() -class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): +class _AsyncOpenTelemetryTransportBase: """Async transport class that will trace all requests made with a client. Args: @@ -877,6 +966,8 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): that is called right before the span ends """ + _module: _HTTPXModule + def __init__( self, transport: httpx.AsyncBaseTransport, @@ -936,7 +1027,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 +1051,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 +1070,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 +1085,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 +1099,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 +1120,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,31 +1174,43 @@ async def aclose(self) -> None: await self._transport.aclose() -class HTTPXClientInstrumentor(BaseInstrumentor): +class _BaseHTTPXClientInstrumentor(BaseInstrumentor): # pylint: disable=protected-access - """An instrumentor for httpx Client and AsyncClient + """Base instrumentor for httpx API-compatible clients. 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): - """Instruments httpx 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: + 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 +1275,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 +1293,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 +1312,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 +1324,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 +1341,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 +1358,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 +1373,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 +1385,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 +1406,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 +1459,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 +1476,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 +1493,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 +1508,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 +1520,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 +1541,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, @@ -1477,10 +1595,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 API-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 @@ -1489,6 +1607,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 +1682,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 +1702,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 +1722,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 +1742,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, @@ -1636,7 +1762,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") @@ -1648,3 +1774,53 @@ 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 + + +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 + + +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: + + 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 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..ac9adc84a3 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.65b0.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 4acd85337e..81ba52d1f5 100644 --- a/tox.ini +++ b/tox.ini @@ -364,7 +364,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 @@ -741,6 +745,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 @@ -1005,7 +1013,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"