From 2a97a2ccfd3a50c93e8b9e0e6906347a2aee2aa8 Mon Sep 17 00:00:00 2001 From: henry3260 Date: Sat, 18 Jul 2026 01:40:46 +0800 Subject: [PATCH 1/2] opentelemetry-instrumentation-httpx: support strict typing --- .changelog/4833.added | 1 + .../instrumentation/httpx/__init__.py | 221 +++++++++++------- pyproject.toml | 2 + tox.ini | 1 + 4 files changed, 139 insertions(+), 86 deletions(-) create mode 100644 .changelog/4833.added diff --git a/.changelog/4833.added b/.changelog/4833.added new file mode 100644 index 0000000000..0bd5f8cfa3 --- /dev/null +++ b/.changelog/4833.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-httpx`: support strict typing 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 a18be5b607..1ada38c292 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -351,13 +351,16 @@ async def async_response_hook(span, request, response): import logging import typing from collections import defaultdict +from collections.abc import MutableMapping from functools import partial from importlib import import_module from inspect import iscoroutinefunction from timeit import default_timer from types import TracebackType -from wrapt import wrap_function_wrapper +from wrapt import ( + wrap_function_wrapper, # pyright: ignore[reportUnknownVariableType] +) from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, @@ -399,7 +402,9 @@ async def async_response_hook(span, request, response): NETWORK_PEER_ADDRESS, NETWORK_PEER_PORT, ) -from opentelemetry.semconv.metrics import MetricInstruments +from opentelemetry.semconv.metrics import ( + MetricInstruments, # pyright: ignore[reportDeprecated] +) from opentelemetry.semconv.metrics.http_metrics import ( HTTP_CLIENT_REQUEST_DURATION, ) @@ -419,8 +424,11 @@ async def async_response_hook(span, request, response): redact_url, sanitize_method, ) +from opentelemetry.util.types import AttributeValue if typing.TYPE_CHECKING: + from typing_extensions import TypeIs + try: import httpx except ImportError: @@ -439,6 +447,12 @@ class _HTTPXModule(typing.Protocol): _logger = logging.getLogger(__name__) +# The old HTTP semantic conventions use a metric name that is only available +# from the deprecated ``MetricInstruments`` class. +_HTTP_CLIENT_DURATION_OLD = ( + MetricInstruments.HTTP_CLIENT_DURATION # pyright: ignore[reportDeprecated] +) + def _try_import(name: str) -> _HTTPXModule | None: try: @@ -460,19 +474,43 @@ def _try_import(name: str) -> _HTTPXModule | None: ] +def _is_async_request_hook( + hook: RequestHook | AsyncRequestHook | None, +) -> TypeIs[AsyncRequestHook]: + return iscoroutinefunction(hook) + + +def _is_sync_request_hook( + hook: RequestHook | AsyncRequestHook | None, +) -> TypeIs[RequestHook]: + return callable(hook) and not iscoroutinefunction(hook) + + +def _is_async_response_hook( + hook: ResponseHook | AsyncResponseHook | None, +) -> TypeIs[AsyncResponseHook]: + return iscoroutinefunction(hook) + + +def _is_sync_response_hook( + hook: ResponseHook | AsyncResponseHook | None, +) -> TypeIs[ResponseHook]: + return callable(hook) and not iscoroutinefunction(hook) + + class RequestInfo(typing.NamedTuple): method: bytes - url: httpx.URL + url: httpx.URL | tuple[bytes, bytes, int | None, bytes] headers: httpx.Headers | None stream: httpx.SyncByteStream | httpx.AsyncByteStream | None - extensions: dict[str, typing.Any] | None + extensions: MutableMapping[str, typing.Any] | None class ResponseInfo(typing.NamedTuple): status_code: int headers: httpx.Headers | None stream: httpx.SyncByteStream | httpx.AsyncByteStream - extensions: dict[str, typing.Any] | None + extensions: MutableMapping[str, typing.Any] | None def _get_default_span_name(method: str) -> str: @@ -486,7 +524,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 module.Headers(headers) def _extract_parameters( @@ -498,13 +536,13 @@ def _extract_parameters( httpx.URL | tuple[bytes, bytes, int | None, bytes], httpx.Headers | None, httpx.SyncByteStream | httpx.AsyncByteStream | None, - dict[str, typing.Any], + MutableMapping[str, typing.Any] | None, ]: 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 = args[0] method = request.method.encode() - url = typing.cast("httpx.URL", module.URL(str(request.url))) + url = module.URL(str(request.url)) headers = request.headers stream = request.stream extensions = request.extensions @@ -537,11 +575,16 @@ def _normalize_url( return str(url) -def _inject_propagation_headers(headers, args, kwargs, module: _HTTPXModule): +def _inject_propagation_headers( + headers: httpx.Headers | None, + args: tuple[typing.Any, ...], + kwargs: dict[str, typing.Any], + module: _HTTPXModule, +) -> None: _headers = _prepare_headers(headers, module) inject(_headers) if isinstance(args[0], module.Request): - request = typing.cast("httpx.Request", args[0]) + request = args[0] request.headers = _headers else: kwargs["headers"] = _headers.raw @@ -575,44 +618,46 @@ def _normalize_headers( def _extract_response( - response: httpx.Response - | tuple[int, httpx.Headers, httpx.SyncByteStream, dict[str, typing.Any]], + response: httpx.Response | tuple[typing.Any, ...], module: _HTTPXModule, ) -> tuple[ int, httpx.Headers, httpx.SyncByteStream | httpx.AsyncByteStream, - dict[str, typing.Any], + MutableMapping[str, typing.Any], str, ]: - 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: + if isinstance(response, tuple): + # In httpx < 0.20.0, handle_request returns + # (status_code, headers, stream, extensions) status_code, headers, stream, extensions = response http_version = extensions.get("http_version", b"HTTP/1.1").decode( "ascii", errors="ignore" ) + else: + status_code = response.status_code + headers = response.headers + stream = response.stream + extensions = response.extensions + http_version = response.http_version return (status_code, headers, stream, extensions, http_version) def _apply_request_client_attributes_to_span( - span_attributes: dict[str, typing.Any], - metric_attributes: dict[str, typing.Any], - url: str | httpx.URL, + span_attributes: dict[str, AttributeValue], + metric_attributes: dict[str, AttributeValue], + url: str | httpx.URL | tuple[bytes, bytes, int | None, bytes], 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 = typing.cast("httpx.URL", module.URL(url)) +) -> None: + if isinstance(url, tuple): + url = _normalize_url(url) + url = module.URL(url) # http semconv transition: http.method -> http.request.method _set_http_method( span_attributes, @@ -673,10 +718,10 @@ def _apply_response_client_attributes_to_span( headers: httpx.Headers | dict[str, list[str] | str] | None = None, captured_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, -): +) -> None: # http semconv transition: http.status_code -> http.response.status_code # TODO: use _set_status when it's stable for http clients - span_attributes = {} + span_attributes: dict[str, AttributeValue] = {} _set_http_status_code( span_attributes, status_code, @@ -711,8 +756,8 @@ def _apply_response_client_attributes_to_span( def _apply_response_client_attributes_to_metrics( - span: Span | None, - metric_attributes: dict[str, typing.Any], + span: Span, + metric_attributes: dict[str, AttributeValue], status_code: int, http_version: str, semconv: _StabilityMode, @@ -782,7 +827,7 @@ def __init__( self._duration_histogram_old = None if _report_old(self._sem_conv_opt_in_mode): self._duration_histogram_old = meter.create_histogram( - name=MetricInstruments.HTTP_CLIENT_DURATION, + name=_HTTP_CLIENT_DURATION_OLD, unit="ms", description="measures the duration of the outbound HTTP request", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, @@ -825,10 +870,7 @@ def handle_request( self, *args: typing.Any, **kwargs: typing.Any, - ) -> ( - tuple[int, httpx.Headers, httpx.SyncByteStream, dict[str, typing.Any]] - | httpx.Response - ): + ) -> httpx.Response: """Add request info to span.""" if not is_http_instrumentation_enabled(): return self._transport.handle_request(*args, **kwargs) @@ -844,8 +886,8 @@ def handle_request( method_original = method.decode() span_name = _get_default_span_name(method_original) - span_attributes = {} - metric_attributes = {} + span_attributes: dict[str, AttributeValue] = {} + metric_attributes: dict[str, AttributeValue] = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, @@ -865,6 +907,7 @@ def handle_request( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: exception = None + response: httpx.Response | tuple[typing.Any, ...] | None = None if callable(self._request_hook): self._request_hook(span, request_info) @@ -947,7 +990,10 @@ def handle_request( elapsed_time, attributes=duration_attrs_new ) - return response + # ``response`` can only be ``None`` when the wrapped transport raised, + # in which case the exception was re-raised above; the tuple form is + # only produced by httpx < 0.20.0, whose transport API expects it. + return typing.cast("httpx.Response", response) def close(self) -> None: self._transport.close() @@ -1000,7 +1046,7 @@ def __init__( self._duration_histogram_old = None if _report_old(self._sem_conv_opt_in_mode): self._duration_histogram_old = meter.create_histogram( - name=MetricInstruments.HTTP_CLIENT_DURATION, + name=_HTTP_CLIENT_DURATION_OLD, unit="ms", description="measures the duration of the outbound HTTP request", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, @@ -1042,10 +1088,7 @@ async def __aexit__( # pylint: disable=R0914 async def handle_async_request( self, *args: typing.Any, **kwargs: typing.Any - ) -> ( - tuple[int, httpx.Headers, httpx.AsyncByteStream, dict[str, typing.Any]] - | httpx.Response - ): + ) -> httpx.Response: """Add request info to span.""" if not is_http_instrumentation_enabled(): return await self._transport.handle_async_request(*args, **kwargs) @@ -1061,8 +1104,8 @@ async def handle_async_request( method_original = method.decode() span_name = _get_default_span_name(method_original) - span_attributes = {} - metric_attributes = {} + span_attributes: dict[str, AttributeValue] = {} + metric_attributes: dict[str, AttributeValue] = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, @@ -1082,6 +1125,7 @@ async def handle_async_request( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: exception = None + response: httpx.Response | tuple[typing.Any, ...] | None = None if callable(self._request_hook): await self._request_hook(span, request_info) @@ -1168,7 +1212,10 @@ async def handle_async_request( elapsed_time, attributes=duration_attrs_new ) - return response + # ``response`` can only be ``None`` when the wrapped transport raised, + # in which case the exception was re-raised above; the tuple form is + # only produced by httpx < 0.20.0, whose transport API expects it. + return typing.cast("httpx.Response", response) async def aclose(self) -> None: await self._transport.aclose() @@ -1189,7 +1236,7 @@ def instrumentation_dependencies(self) -> typing.Collection[str]: return self._instrumentation_dependencies # pylint: disable=too-many-locals - def _instrument(self, **kwargs: typing.Any): + def _instrument(self, **kwargs: typing.Any) -> None: """Instrument the configured httpx API-compatible clients. Args: @@ -1260,7 +1307,7 @@ def _instrument(self, **kwargs: typing.Any): duration_histogram_old = None if _report_old(sem_conv_opt_in_mode): duration_histogram_old = meter.create_histogram( - name=MetricInstruments.HTTP_CLIENT_DURATION, + name=_HTTP_CLIENT_DURATION_OLD, unit="ms", description="measures the duration of the outbound HTTP request", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, @@ -1311,7 +1358,7 @@ def _instrument(self, **kwargs: typing.Any): ), ) - def _uninstrument(self, **kwargs: typing.Any): + def _uninstrument(self, **kwargs: typing.Any) -> None: module = self._module if module is None: raise ModuleNotFoundError(f"{self._module_name} must be installed") @@ -1327,16 +1374,16 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals *, module: _HTTPXModule, tracer: Tracer, - duration_histogram_old: Histogram, - duration_histogram_new: Histogram, + duration_histogram_old: Histogram | None, + duration_histogram_new: Histogram | None, sem_conv_opt_in_mode: _StabilityMode, - request_hook: RequestHook, - response_hook: ResponseHook, + request_hook: RequestHook | None, + response_hook: ResponseHook | None, excluded_urls: ExcludeList | None, captured_request_headers: list[str] | None = None, captured_response_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, - ): + ) -> typing.Any: if not is_http_instrumentation_enabled(): return wrapped(*args, **kwargs) @@ -1349,8 +1396,8 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals method_original = method.decode() span_name = _get_default_span_name(method_original) - span_attributes = {} - metric_attributes = {} + span_attributes: dict[str, AttributeValue] = {} + metric_attributes: dict[str, AttributeValue] = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, @@ -1370,6 +1417,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: exception = None + response: httpx.Response | tuple[typing.Any, ...] | None = None if callable(request_hook): request_hook(span, request_info) @@ -1462,16 +1510,16 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals *, module: _HTTPXModule, tracer: Tracer, - duration_histogram_old: Histogram, - duration_histogram_new: Histogram, + duration_histogram_old: Histogram | None, + duration_histogram_new: Histogram | None, sem_conv_opt_in_mode: _StabilityMode, - async_request_hook: AsyncRequestHook, - async_response_hook: AsyncResponseHook, + async_request_hook: AsyncRequestHook | None, + async_response_hook: AsyncResponseHook | None, excluded_urls: ExcludeList | None, - captured_request_headers: typing.Optional[list[str]] = None, - captured_response_headers: typing.Optional[list[str]] = None, - sensitive_headers: typing.Optional[list[str]] = None, - ): + captured_request_headers: list[str] | None = None, + captured_response_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, + ) -> typing.Any: if not is_http_instrumentation_enabled(): return await wrapped(*args, **kwargs) @@ -1484,8 +1532,8 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals method_original = method.decode() span_name = _get_default_span_name(method_original) - span_attributes = {} - metric_attributes = {} + span_attributes: dict[str, AttributeValue] = {} + metric_attributes: dict[str, AttributeValue] = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, @@ -1505,6 +1553,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: exception = None + response: httpx.Response | tuple[typing.Any, ...] | None = None if callable(async_request_hook): await async_request_hook(span, request_info) @@ -1637,7 +1686,7 @@ def instrument_client( duration_histogram_old = None if _report_old(sem_conv_opt_in_mode): duration_histogram_old = meter.create_histogram( - name=MetricInstruments.HTTP_CLIENT_DURATION, + name=_HTTP_CLIENT_DURATION_OLD, unit="ms", description="measures the duration of the outbound HTTP request", explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, @@ -1651,19 +1700,19 @@ def instrument_client( explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, ) - if iscoroutinefunction(request_hook): + sync_request_hook: RequestHook | None = None + async_request_hook: AsyncRequestHook | None = None + if _is_async_request_hook(request_hook): async_request_hook = request_hook - request_hook = None - else: - # request_hook already set - async_request_hook = None + elif _is_sync_request_hook(request_hook): + sync_request_hook = request_hook - if iscoroutinefunction(response_hook): + sync_response_hook: ResponseHook | None = None + async_response_hook: AsyncResponseHook | None = None + if _is_async_response_hook(response_hook): async_response_hook = response_hook - response_hook = None - else: - # response_hook already set - async_response_hook = None + elif _is_sync_response_hook(response_hook): + sync_response_hook = response_hook excluded_urls = get_excluded_urls("HTTPX") captured_request_headers: list[str] = get_custom_headers( @@ -1687,8 +1736,8 @@ def instrument_client( duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, sem_conv_opt_in_mode=sem_conv_opt_in_mode, - request_hook=request_hook, - response_hook=response_hook, + request_hook=sync_request_hook, + response_hook=sync_response_hook, excluded_urls=excluded_urls, captured_request_headers=captured_request_headers, captured_response_headers=captured_response_headers, @@ -1707,15 +1756,15 @@ def instrument_client( duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, sem_conv_opt_in_mode=sem_conv_opt_in_mode, - request_hook=request_hook, - response_hook=response_hook, + request_hook=sync_request_hook, + response_hook=sync_response_hook, excluded_urls=excluded_urls, captured_request_headers=captured_request_headers, captured_response_headers=captured_response_headers, sensitive_headers=sensitive_headers, ), ) - client._is_instrumented_by_opentelemetry = True + setattr(client, "_is_instrumented_by_opentelemetry", True) if hasattr(client._transport, "handle_async_request"): wrap_function_wrapper( client._transport, @@ -1755,7 +1804,7 @@ def instrument_client( sensitive_headers=sensitive_headers, ), ) - client._is_instrumented_by_opentelemetry = True + setattr(client, "_is_instrumented_by_opentelemetry", True) @staticmethod def uninstrument_client(client: httpx.Client | httpx.AsyncClient) -> None: @@ -1768,12 +1817,12 @@ def uninstrument_client(client: httpx.Client | httpx.AsyncClient) -> None: unwrap(client._transport, "handle_request") for transport in client._mounts.values(): unwrap(transport, "handle_request") - client._is_instrumented_by_opentelemetry = False + setattr(client, "_is_instrumented_by_opentelemetry", False) elif hasattr(client._transport, "handle_async_request"): unwrap(client._transport, "handle_async_request") for transport in client._mounts.values(): unwrap(transport, "handle_async_request") - client._is_instrumented_by_opentelemetry = False + setattr(client, "_is_instrumented_by_opentelemetry", False) if _httpx_module is not None: diff --git a/pyproject.toml b/pyproject.toml index 4c90657e1c..6d4636889e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,6 +205,7 @@ include = [ "instrumentation/opentelemetry-instrumentation-aiohttp-client", "instrumentation/opentelemetry-instrumentation-aiokafka", "instrumentation/opentelemetry-instrumentation-asyncclick", + "instrumentation/opentelemetry-instrumentation-httpx", "instrumentation/opentelemetry-instrumentation-threading", "instrumentation-genai/opentelemetry-instrumentation-anthropic", "instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk", @@ -236,6 +237,7 @@ exclude = [ "instrumentation-genai/opentelemetry-instrumentation-weaviate/examples/**/*.py", "util/opentelemetry-util-genai/tests/**/*.py", "instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/**/*.py", + "instrumentation/opentelemetry-instrumentation-httpx/tests/**/*.py", "opamp/opentelemetry-opamp-client/tests/**/*.py", "opamp/opentelemetry-opamp-client/src/opentelemetry/**/proto/**", ] diff --git a/tox.ini b/tox.ini index 81ba52d1f5..270b4cccc2 100644 --- a/tox.ini +++ b/tox.ini @@ -1193,6 +1193,7 @@ deps = {toxinidir}/instrumentation/opentelemetry-instrumentation-asyncclick[instruments] {toxinidir}/exporter/opentelemetry-exporter-credential-provider-gcp {toxinidir}/instrumentation/opentelemetry-instrumentation-aiohttp-client[instruments] + {toxinidir}/instrumentation/opentelemetry-instrumentation-httpx[instruments-any] {toxinidir}/opamp/opentelemetry-opamp-client commands = From 8a8ba916bbb25078caaba6a3ad40550597f0b29e Mon Sep 17 00:00:00 2001 From: henry3260 Date: Sat, 18 Jul 2026 10:09:34 +0800 Subject: [PATCH 2/2] opentelemetry-instrumentation-httpx: fix IPv6 raw URL tuple handling on httpx < 0.20 --- .../instrumentation/httpx/__init__.py | 19 ++++++---- .../tests/test_httpx_integration.py | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 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 1ada38c292..53832550e1 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -438,7 +438,13 @@ class _HTTPXModule(typing.Protocol): Request: type[httpx.Request] Response: type[httpx.Response] Headers: type[httpx.Headers] - URL: type[httpx.URL] + # Declared as a callable rather than type[httpx.URL] because the + # URL constructor of httpx < 0.20.0 additionally accepts the raw + # URL tuple its transport API passes around. + URL: typing.Callable[ + [str | httpx.URL | tuple[bytes, bytes, int | None, bytes]], + httpx.URL, + ] BaseTransport: type[httpx.BaseTransport] AsyncBaseTransport: type[httpx.AsyncBaseTransport] HTTPTransport: type[httpx.HTTPTransport] @@ -619,7 +625,6 @@ def _normalize_headers( def _extract_response( response: httpx.Response | tuple[typing.Any, ...], - module: _HTTPXModule, ) -> tuple[ int, httpx.Headers, @@ -655,8 +660,6 @@ def _apply_request_client_attributes_to_span( captured_headers: list[str] | None = None, sensitive_headers: list[str] | None = None, ) -> None: - if isinstance(url, tuple): - url = _normalize_url(url) url = module.URL(url) # http semconv transition: http.method -> http.request.method _set_http_method( @@ -925,7 +928,7 @@ def handle_request( if isinstance(response, (self._module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response, self._module) + _extract_response(response) ) # Always apply response attributes to metrics @@ -1145,7 +1148,7 @@ async def handle_async_request( if isinstance(response, (self._module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response, self._module) + _extract_response(response) ) # Always apply response attributes to metrics @@ -1435,7 +1438,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals if isinstance(response, (module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response, module) + _extract_response(response) ) # Always apply response attributes to metrics @@ -1571,7 +1574,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals if isinstance(response, (module.Response, tuple)): status_code, headers, stream, extensions, http_version = ( - _extract_response(response, module) + _extract_response(response) ) # Always apply response attributes to metrics diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index ee669e6c1b..527a097b52 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -7,6 +7,7 @@ import asyncio import inspect import typing +import unittest from unittest import mock import httpx @@ -25,11 +26,13 @@ HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, OTEL_SEMCONV_STABILITY_OPT_IN, _OpenTelemetrySemanticConventionStability, + _StabilityMode, ) from opentelemetry.instrumentation.httpx import ( AsyncOpenTelemetryTransport, HTTPXClientInstrumentor, SyncOpenTelemetryTransport, + _apply_request_client_attributes_to_span, ) from opentelemetry.instrumentation.utils import suppress_http_instrumentation from opentelemetry.propagate import get_global_textmap, set_global_textmap @@ -2179,3 +2182,37 @@ class CustomAsyncClient(httpx.AsyncClient): self.assertTrue( isinstance(client._transport.handle_async_request, BaseObjectProxy) ) + + +class TestRawURLTupleAttributes(unittest.TestCase): + """httpx < 0.20 passes raw URL tuples to the transport layer.""" + + def _apply_attributes(self, url_tuple): + module = opentelemetry.instrumentation.httpx._httpx_module + try: + module.URL(url_tuple) + except TypeError: + self.skipTest("this httpx version does not accept raw URL tuples") + span_attributes = {} + metric_attributes = {} + _apply_request_client_attributes_to_span( + span_attributes, + metric_attributes, + url_tuple, + "GET", + _StabilityMode.DEFAULT, + module, + ) + return span_attributes + + def test_ipv6_host_is_bracketed(self): + span_attributes = self._apply_attributes( + (b"http", b"::1", 8080, b"/foo") + ) + self.assertEqual(span_attributes[HTTP_URL], "http://[::1]:8080/foo") + + def test_port_zero_is_kept(self): + span_attributes = self._apply_attributes( + (b"http", b"example.com", 0, b"/") + ) + self.assertEqual(span_attributes[HTTP_URL], "http://example.com:0/")