diff --git a/CHANGELOG.md b/CHANGELOG.md index efb766bda4..234e93fdce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-botocore` Use `cloud.region` instead of `aws.region` span attribute as per semantic conventions. ([#3474](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3474)) +### Added + +- `opentelemetry-instrumentation-httpx` Add support for HTTP metrics + ([#3513](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3513)) + ## Version 1.33.0/0.54b0 (2025-05-09) diff --git a/instrumentation/README.md b/instrumentation/README.md index 4c89015b78..674532bd04 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -25,7 +25,7 @@ | [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | 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 | No | migration +| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.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/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index ef70c244e7..ead4b378d9 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -209,22 +209,30 @@ async def async_response_hook(span, request, response): import typing from asyncio import iscoroutinefunction from functools import partial +from timeit import default_timer from types import TracebackType import httpx from wrapt import wrap_function_wrapper from opentelemetry.instrumentation._semconv import ( + _client_duration_attrs_new, + _client_duration_attrs_old, + _filter_semconv_duration_attrs, _get_schema_url, _OpenTelemetrySemanticConventionStability, _OpenTelemetryStabilitySignalType, _report_new, + _report_old, _set_http_host_client, _set_http_method, + _set_http_net_peer_name_client, _set_http_network_protocol_version, _set_http_peer_port_client, + _set_http_scheme, _set_http_status_code, _set_http_url, + _set_status, _StabilityMode, ) from opentelemetry.instrumentation.httpx.package import _instruments @@ -235,12 +243,17 @@ async def async_response_hook(span, request, response): is_http_instrumentation_enabled, unwrap, ) +from opentelemetry.metrics import Histogram, MeterProvider, get_meter from opentelemetry.propagate import inject from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv.attributes.network_attributes import ( NETWORK_PEER_ADDRESS, NETWORK_PEER_PORT, ) +from opentelemetry.semconv.metrics import MetricInstruments +from opentelemetry.semconv.metrics.http_metrics import ( + HTTP_CLIENT_REQUEST_DURATION, +) from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer from opentelemetry.trace.span import Span from opentelemetry.trace.status import StatusCode @@ -352,6 +365,7 @@ def _extract_response( def _apply_request_client_attributes_to_span( span_attributes: dict[str, typing.Any], + metric_attributes: dict[str, typing.Any], url: str | httpx.URL, method_original: str, semconv: _StabilityMode, @@ -364,23 +378,44 @@ def _apply_request_client_attributes_to_span( sanitize_method(method_original), semconv, ) + # http semconv transition: http.url -> url.full _set_http_url(span_attributes, str(url), semconv) + # Set HTTP method in metric labels + _set_http_method( + metric_attributes, + method_original, + sanitize_method(method_original), + semconv, + ) + + if _report_old(semconv): + # TODO: Support opt-in for url.scheme in new semconv + _set_http_scheme(metric_attributes, url.scheme, semconv) + if _report_new(semconv): if url.host: # http semconv transition: http.host -> server.address _set_http_host_client(span_attributes, url.host, semconv) + # Add metric labels + _set_http_host_client(metric_attributes, url.host, semconv) + _set_http_net_peer_name_client( + metric_attributes, url.host, semconv + ) # http semconv transition: net.sock.peer.addr -> network.peer.address span_attributes[NETWORK_PEER_ADDRESS] = url.host if url.port: # http semconv transition: net.sock.peer.port -> network.peer.port _set_http_peer_port_client(span_attributes, url.port, semconv) span_attributes[NETWORK_PEER_PORT] = url.port + # Add metric labels + _set_http_peer_port_client(metric_attributes, url.port, semconv) def _apply_response_client_attributes_to_span( span: Span, + metric_attributes: dict[str, typing.Any], status_code: int, http_version: str, semconv: _StabilityMode, @@ -396,6 +431,16 @@ def _apply_response_client_attributes_to_span( http_status_code = http_status_to_status_code(status_code) span.set_status(http_status_code) + # Set HTTP status code in metric attributes + _set_status( + span, + metric_attributes, + status_code, + str(status_code), + server_span=False, + sem_conv_opt_in_mode=semconv, + ) + if http_status_code == StatusCode.ERROR and _report_new(semconv): # http semconv transition: new error.type span_attributes[ERROR_TYPE] = str(status_code) @@ -403,10 +448,16 @@ def _apply_response_client_attributes_to_span( if http_version and _report_new(semconv): # http semconv transition: http.flavor -> network.protocol.version _set_http_network_protocol_version( - span_attributes, + metric_attributes, http_version.replace("HTTP/", ""), semconv, ) + if _report_new(semconv): + _set_http_network_protocol_version( + span_attributes, + http_version.replace("HTTP/", ""), + semconv, + ) for key, val in span_attributes.items(): span.set_attribute(key, val) @@ -418,6 +469,7 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport): Args: transport: SyncHTTPTransport instance to wrap tracer_provider: Tracer provider to use + meter_provider: Meter provider to use request_hook: A hook that receives the span and request that is called right after the span is created response_hook: A hook that receives the span, request, and response @@ -428,6 +480,7 @@ def __init__( self, transport: httpx.BaseTransport, tracer_provider: TracerProvider | None = None, + meter_provider: MeterProvider | None = None, request_hook: RequestHook | None = None, response_hook: ResponseHook | None = None, ): @@ -435,14 +488,36 @@ def __init__( self._sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( _OpenTelemetryStabilitySignalType.HTTP, ) + schema_url = _get_schema_url(self._sem_conv_opt_in_mode) self._transport = transport self._tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url=_get_schema_url(self._sem_conv_opt_in_mode), + schema_url=schema_url, ) + meter = get_meter( + __name__, + __version__, + meter_provider, + schema_url, + ) + + 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, + unit="ms", + description="measures the duration of the outbound HTTP request", + ) + self._duration_histogram_new = None + if _report_new(self._sem_conv_opt_in_mode): + self._duration_histogram_new = meter.create_histogram( + name=HTTP_CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) self._request_hook = request_hook self._response_hook = response_hook @@ -477,9 +552,11 @@ def handle_request( method_original = method.decode() span_name = _get_default_span_name(method_original) span_attributes = {} + metric_attributes = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, + metric_attributes, url, method_original, self._sem_conv_opt_in_mode, @@ -496,11 +573,15 @@ def handle_request( _inject_propagation_headers(headers, args, kwargs) + start_time = default_timer() + try: response = self._transport.handle_request(*args, **kwargs) except Exception as exc: # pylint: disable=W0703 exception = exc response = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) if isinstance(response, (httpx.Response, tuple)): status_code, headers, stream, extensions, http_version = ( @@ -511,6 +592,7 @@ def handle_request( # apply http client response attributes according to semconv _apply_response_client_attributes_to_span( span, + metric_attributes, status_code, http_version, self._sem_conv_opt_in_mode, @@ -529,8 +611,33 @@ def handle_request( span.set_attribute( ERROR_TYPE, type(exception).__qualname__ ) + metric_attributes[ERROR_TYPE] = type( + exception + ).__qualname__ raise exception.with_traceback(exception.__traceback__) + if self._duration_histogram_old is not None: + duration_attrs_old = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.DEFAULT, + ) + self._duration_histogram_old.record( + max(round(elapsed_time * 1000), 0), + attributes=duration_attrs_old, + ) + if self._duration_histogram_new is not None: + duration_attrs_new = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + self._duration_histogram_new.record( + elapsed_time, attributes=duration_attrs_new + ) + return response def close(self) -> None: @@ -543,6 +650,7 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport): Args: transport: AsyncHTTPTransport instance to wrap tracer_provider: Tracer provider to use + meter_provider: Meter provider to use request_hook: A hook that receives the span and request that is called right after the span is created response_hook: A hook that receives the span, request, and response @@ -553,6 +661,7 @@ def __init__( self, transport: httpx.AsyncBaseTransport, tracer_provider: TracerProvider | None = None, + meter_provider: MeterProvider | None = None, request_hook: AsyncRequestHook | None = None, response_hook: AsyncResponseHook | None = None, ): @@ -560,14 +669,38 @@ def __init__( self._sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( _OpenTelemetryStabilitySignalType.HTTP, ) + schema_url = _get_schema_url(self._sem_conv_opt_in_mode) self._transport = transport self._tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url=_get_schema_url(self._sem_conv_opt_in_mode), + schema_url=schema_url, ) + + meter = get_meter( + __name__, + __version__, + meter_provider, + schema_url, + ) + + 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, + unit="ms", + description="measures the duration of the outbound HTTP request", + ) + self._duration_histogram_new = None + if _report_new(self._sem_conv_opt_in_mode): + self._duration_histogram_new = meter.create_histogram( + name=HTTP_CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) + self._request_hook = request_hook self._response_hook = response_hook @@ -600,9 +733,11 @@ async def handle_async_request( method_original = method.decode() span_name = _get_default_span_name(method_original) span_attributes = {} + metric_attributes = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, + metric_attributes, url, method_original, self._sem_conv_opt_in_mode, @@ -619,6 +754,8 @@ async def handle_async_request( _inject_propagation_headers(headers, args, kwargs) + start_time = default_timer() + try: response = await self._transport.handle_async_request( *args, **kwargs @@ -626,6 +763,8 @@ async def handle_async_request( except Exception as exc: # pylint: disable=W0703 exception = exc response = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) if isinstance(response, (httpx.Response, tuple)): status_code, headers, stream, extensions, http_version = ( @@ -636,6 +775,7 @@ async def handle_async_request( # apply http client response attributes according to semconv _apply_response_client_attributes_to_span( span, + metric_attributes, status_code, http_version, self._sem_conv_opt_in_mode, @@ -655,8 +795,34 @@ async def handle_async_request( span.set_attribute( ERROR_TYPE, type(exception).__qualname__ ) + metric_attributes[ERROR_TYPE] = type( + exception + ).__qualname__ + raise exception.with_traceback(exception.__traceback__) + if self._duration_histogram_old is not None: + duration_attrs_old = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.DEFAULT, + ) + self._duration_histogram_old.record( + max(round(elapsed_time * 1000), 0), + attributes=duration_attrs_old, + ) + if self._duration_histogram_new is not None: + duration_attrs_new = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + self._duration_histogram_new.record( + elapsed_time, attributes=duration_attrs_new + ) + return response async def aclose(self) -> None: @@ -679,6 +845,7 @@ def _instrument(self, **kwargs: typing.Any): 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, @@ -687,6 +854,7 @@ def _instrument(self, **kwargs: typing.Any): ``async_response_hook``: Async``response_hook`` for ``httpx.AsyncClient`` """ tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") request_hook = kwargs.get("request_hook") response_hook = kwargs.get("response_hook") async_request_hook = kwargs.get("async_request_hook") @@ -706,12 +874,35 @@ def _instrument(self, **kwargs: typing.Any): sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( _OpenTelemetryStabilitySignalType.HTTP, ) + schema_url = _get_schema_url(sem_conv_opt_in_mode) + tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url=_get_schema_url(sem_conv_opt_in_mode), + schema_url=schema_url, + ) + + meter = get_meter( + __name__, + __version__, + meter_provider, + schema_url, ) + duration_histogram_old = None + if _report_old(sem_conv_opt_in_mode): + duration_histogram_old = meter.create_histogram( + name=MetricInstruments.HTTP_CLIENT_DURATION, + unit="ms", + description="measures the duration of the outbound HTTP request", + ) + duration_histogram_new = None + if _report_new(sem_conv_opt_in_mode): + duration_histogram_new = meter.create_histogram( + name=HTTP_CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) wrap_function_wrapper( "httpx", @@ -719,6 +910,8 @@ def _instrument(self, **kwargs: typing.Any): partial( self._handle_request_wrapper, tracer=tracer, + 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, @@ -730,6 +923,8 @@ def _instrument(self, **kwargs: typing.Any): partial( self._handle_async_request_wrapper, tracer=tracer, + duration_histogram_old=duration_histogram_old, + duration_histogram_new=duration_histogram_new, sem_conv_opt_in_mode=sem_conv_opt_in_mode, async_request_hook=async_request_hook, async_response_hook=async_response_hook, @@ -747,6 +942,8 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], tracer: Tracer, + duration_histogram_old: Histogram, + duration_histogram_new: Histogram, sem_conv_opt_in_mode: _StabilityMode, request_hook: RequestHook, response_hook: ResponseHook, @@ -760,9 +957,11 @@ 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 = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, + metric_attributes, url, method_original, sem_conv_opt_in_mode, @@ -779,11 +978,15 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals _inject_propagation_headers(headers, args, kwargs) + start_time = default_timer() + try: response = wrapped(*args, **kwargs) except Exception as exc: # pylint: disable=W0703 exception = exc response = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) if isinstance(response, (httpx.Response, tuple)): status_code, headers, stream, extensions, http_version = ( @@ -794,6 +997,7 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals # apply http client response attributes according to semconv _apply_response_client_attributes_to_span( span, + metric_attributes, status_code, http_version, sem_conv_opt_in_mode, @@ -810,8 +1014,33 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals span.set_attribute( ERROR_TYPE, type(exception).__qualname__ ) + metric_attributes[ERROR_TYPE] = type( + exception + ).__qualname__ raise exception.with_traceback(exception.__traceback__) + if duration_histogram_old is not None: + duration_attrs_old = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.DEFAULT, + ) + duration_histogram_old.record( + max(round(elapsed_time * 1000), 0), + attributes=duration_attrs_old, + ) + if duration_histogram_new is not None: + duration_attrs_new = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + duration_histogram_new.record( + elapsed_time, attributes=duration_attrs_new + ) + return response @staticmethod @@ -821,6 +1050,8 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], tracer: Tracer, + duration_histogram_old: Histogram, + duration_histogram_new: Histogram, sem_conv_opt_in_mode: _StabilityMode, async_request_hook: AsyncRequestHook, async_response_hook: AsyncResponseHook, @@ -834,9 +1065,11 @@ 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 = {} # apply http client response attributes according to semconv _apply_request_client_attributes_to_span( span_attributes, + metric_attributes, url, method_original, sem_conv_opt_in_mode, @@ -853,11 +1086,15 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals _inject_propagation_headers(headers, args, kwargs) + start_time = default_timer() + try: response = await wrapped(*args, **kwargs) except Exception as exc: # pylint: disable=W0703 exception = exc response = getattr(exc, "response", None) + finally: + elapsed_time = max(default_timer() - start_time, 0) if isinstance(response, (httpx.Response, tuple)): status_code, headers, stream, extensions, http_version = ( @@ -868,6 +1105,7 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals # apply http client response attributes according to semconv _apply_response_client_attributes_to_span( span, + metric_attributes, status_code, http_version, sem_conv_opt_in_mode, @@ -887,13 +1125,37 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals ) raise exception.with_traceback(exception.__traceback__) + if duration_histogram_old is not None: + duration_attrs_old = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.DEFAULT, + ) + duration_histogram_old.record( + max(round(elapsed_time * 1000), 0), + attributes=duration_attrs_old, + ) + if duration_histogram_new is not None: + duration_attrs_new = _filter_semconv_duration_attrs( + metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ) + duration_histogram_new.record( + elapsed_time, attributes=duration_attrs_new + ) + return response + # pylint: disable=too-many-branches @classmethod def instrument_client( cls, client: httpx.Client | httpx.AsyncClient, tracer_provider: TracerProvider | None = None, + meter_provider: MeterProvider | None = None, request_hook: RequestHook | AsyncRequestHook | None = None, response_hook: ResponseHook | AsyncResponseHook | None = None, ) -> None: @@ -902,6 +1164,7 @@ def instrument_client( Args: client: The httpx 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 right after the span is created response_hook: A hook that receives the span, request, and response @@ -918,12 +1181,33 @@ def instrument_client( sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( _OpenTelemetryStabilitySignalType.HTTP, ) + schema_url = _get_schema_url(sem_conv_opt_in_mode) tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url=_get_schema_url(sem_conv_opt_in_mode), + schema_url=schema_url, ) + meter = get_meter( + __name__, + __version__, + meter_provider, + schema_url, + ) + duration_histogram_old = None + if _report_old(sem_conv_opt_in_mode): + duration_histogram_old = meter.create_histogram( + name=MetricInstruments.HTTP_CLIENT_DURATION, + unit="ms", + description="measures the duration of the outbound HTTP request", + ) + duration_histogram_new = None + if _report_new(sem_conv_opt_in_mode): + duration_histogram_new = meter.create_histogram( + name=HTTP_CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) if iscoroutinefunction(request_hook): async_request_hook = request_hook @@ -946,6 +1230,8 @@ def instrument_client( partial( cls._handle_request_wrapper, tracer=tracer, + 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, @@ -959,6 +1245,8 @@ def instrument_client( partial( cls._handle_request_wrapper, tracer=tracer, + 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, @@ -972,6 +1260,8 @@ def instrument_client( partial( cls._handle_async_request_wrapper, tracer=tracer, + duration_histogram_old=duration_histogram_old, + duration_histogram_new=duration_histogram_new, sem_conv_opt_in_mode=sem_conv_opt_in_mode, async_request_hook=async_request_hook, async_response_hook=async_response_hook, @@ -985,6 +1275,8 @@ def instrument_client( partial( cls._handle_async_request_wrapper, tracer=tracer, + duration_histogram_old=duration_histogram_old, + duration_histogram_new=duration_histogram_new, sem_conv_opt_in_mode=sem_conv_opt_in_mode, async_request_hook=async_request_hook, async_response_hook=async_response_hook, 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 633e01c8b1..724eed7dca 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py @@ -15,6 +15,6 @@ _instruments = ("httpx >= 0.18.0",) -_supports_metrics = False +_supports_metrics = True _semconv_status = "migration" diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 148fe27893..8627ff5473 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -67,6 +67,7 @@ ResponseHook, ResponseInfo, ) + from opentelemetry.metrics import MeterProvider from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import TracerProvider from opentelemetry.trace.span import Span @@ -187,6 +188,11 @@ def assert_span( return span_list[0] return span_list + def assert_metrics(self, num_metrics: int = 1): + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), num_metrics) + return metrics + @abc.abstractmethod def perform_request( self, @@ -220,6 +226,21 @@ def test_basic(self): span, opentelemetry.instrumentation.httpx ) + def test_basic_metrics(self): + self.perform_request(self.URL) + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual(duration_data_point.count, 1) + self.assertEqual( + dict(duration_data_point.attributes), + { + SpanAttributes.HTTP_STATUS_CODE: 200, + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_SCHEME: "http", + }, + ) + def test_nonstandard_http_method(self): respx.route(method="NONSTANDARD").mock( return_value=httpx.Response(405) @@ -243,6 +264,19 @@ def test_nonstandard_http_method(self): self.assertEqualSpanInstrumentationScope( span, opentelemetry.instrumentation.httpx ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual(duration_data_point.count, 1) + self.assertEqual( + dict(duration_data_point.attributes), + { + SpanAttributes.HTTP_STATUS_CODE: 405, + SpanAttributes.HTTP_METHOD: "_OTHER", + SpanAttributes.HTTP_SCHEME: "http", + }, + ) def test_nonstandard_http_method_new_semconv(self): respx.route(method="NONSTANDARD").mock( @@ -272,6 +306,21 @@ def test_nonstandard_http_method_new_semconv(self): self.assertEqualSpanInstrumentationScope( span, opentelemetry.instrumentation.httpx ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual(duration_data_point.count, 1) + self.assertEqual( + dict(duration_data_point.attributes), + { + HTTP_REQUEST_METHOD: "_OTHER", + SERVER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 405, + NETWORK_PROTOCOL_VERSION: "1.1", + ERROR_TYPE: "405", + }, + ) def test_basic_new_semconv(self): url = "http://mock:8080/status/200" @@ -313,6 +362,22 @@ def test_basic_new_semconv(self): span, opentelemetry.instrumentation.httpx ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual(duration_data_point.count, 1) + self.assertEqual( + dict(duration_data_point.attributes), + { + SERVER_ADDRESS: "mock", + HTTP_REQUEST_METHOD: "GET", + HTTP_RESPONSE_STATUS_CODE: 200, + NETWORK_PROTOCOL_VERSION: "1.1", + SERVER_PORT: 8080, + }, + ) + def test_basic_both_semconv(self): url = "http://mock:8080/status/200" # 8080 because httpx returns None for common ports (http, https, wss) respx.get(url).mock(httpx.Response(200, text="Hello!")) @@ -354,6 +419,34 @@ def test_basic_both_semconv(self): span, opentelemetry.instrumentation.httpx ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 2) + # New convention + self.assertEqual( + dict(metrics[0].data.data_points[0].attributes), + { + SpanAttributes.HTTP_FLAVOR: "1.1", + SpanAttributes.HTTP_HOST: "mock", + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_SCHEME: "http", + SpanAttributes.NET_PEER_NAME: "mock", + SpanAttributes.NET_PEER_PORT: 8080, + SpanAttributes.HTTP_STATUS_CODE: 200, + }, + ) + # Old convention + self.assertEqual( + dict(metrics[1].data.data_points[0].attributes), + { + HTTP_REQUEST_METHOD: "GET", + SERVER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 200, + NETWORK_PROTOCOL_VERSION: "1.1", + SERVER_PORT: 8080, + }, + ) + def test_basic_multiple(self): self.perform_request(self.URL) self.perform_request(self.URL) @@ -375,6 +468,16 @@ def test_not_foundbasic(self): span.status.status_code, trace.StatusCode.ERROR, ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual( + duration_data_point.attributes.get( + SpanAttributes.HTTP_STATUS_CODE + ), + 404, + ) def test_not_foundbasic_new_semconv(self): url_404 = "http://mock/status/404" @@ -395,6 +498,17 @@ def test_not_foundbasic_new_semconv(self): span.status.status_code, trace.StatusCode.ERROR, ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 1) + duration_data_point = metrics[0].data.data_points[0] + self.assertEqual( + duration_data_point.attributes.get(HTTP_RESPONSE_STATUS_CODE), + 404, + ) + self.assertEqual( + duration_data_point.attributes.get(ERROR_TYPE), "404" + ) def test_not_foundbasic_both_semconv(self): url_404 = "http://mock/status/404" @@ -417,6 +531,30 @@ def test_not_foundbasic_both_semconv(self): span.status.status_code, trace.StatusCode.ERROR, ) + # Validate metrics + metrics = self.get_sorted_metrics() + self.assertEqual(len(metrics), 2) + # Old convention + self.assertEqual( + metrics[0] + .data.data_points[0] + .attributes.get(SpanAttributes.HTTP_STATUS_CODE), + 404, + ) + self.assertEqual( + metrics[0].data.data_points[0].attributes.get(ERROR_TYPE), None + ) + # New convention + self.assertEqual( + metrics[1] + .data.data_points[0] + .attributes.get(HTTP_RESPONSE_STATUS_CODE), + 404, + ) + self.assertEqual( + metrics[1].data.data_points[0].attributes.get(ERROR_TYPE), + "404", + ) def test_suppress_instrumentation(self): with suppress_http_instrumentation(): @@ -584,6 +722,7 @@ class BaseManualTest(BaseTest, metaclass=abc.ABCMeta): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, + meter_provider: typing.Optional["MeterProvider"] = None, request_hook: typing.Optional["RequestHook"] = None, response_hook: typing.Optional["ResponseHook"] = None, **kwargs, @@ -623,6 +762,18 @@ def test_custom_tracer_provider(self): span = self.assert_span(exporter=exporter) self.assertIs(span.resource, resource) + def test_custom_meter_provider(self): + meter_provider, memory_reader = self.create_meter_provider() + transport = self.create_transport(meter_provider=meter_provider) + client = self.create_client(transport) + self.perform_request(self.URL, client=client) + metrics = memory_reader.get_metrics_data().resource_metrics[0] + self.assertEqual(len(metrics.scope_metrics), 1) + data_point = ( + metrics.scope_metrics[0].metrics[0].data.data_points[0] + ) + self.assertEqual(data_point.count, 1) + def test_response_hook(self): transport = self.create_transport( tracer_provider=self.tracer_provider, @@ -1096,6 +1247,7 @@ def tearDown(self): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, + meter_provider: typing.Optional["MeterProvider"] = None, request_hook: typing.Optional["RequestHook"] = None, response_hook: typing.Optional["ResponseHook"] = None, **kwargs, @@ -1104,6 +1256,7 @@ def create_transport( telemetry_transport = SyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, + meter_provider=meter_provider, request_hook=request_hook, response_hook=response_hook, ) @@ -1127,6 +1280,11 @@ def perform_request( return self.client.request(method, url, headers=headers) return client.request(method, url, headers=headers) + def test_basic(self): + self.perform_request(self.URL) + self.assert_span(num_spans=1) + self.assert_metrics(num_metrics=1) + def test_credential_removal(self): new_url = "http://username:password@mock/status/200" self.perform_request(new_url) @@ -1148,6 +1306,7 @@ def setUp(self): def create_transport( self, tracer_provider: typing.Optional["TracerProvider"] = None, + meter_provider: typing.Optional["MeterProvider"] = None, request_hook: typing.Optional["AsyncRequestHook"] = None, response_hook: typing.Optional["AsyncResponseHook"] = None, **kwargs, @@ -1156,6 +1315,7 @@ def create_transport( telemetry_transport = AsyncOpenTelemetryTransport( transport, tracer_provider=tracer_provider, + meter_provider=meter_provider, request_hook=request_hook, response_hook=response_hook, ) @@ -1195,6 +1355,7 @@ def test_basic_multiple(self): self.URL, client=self.create_client(self.transport) ) self.assert_span(num_spans=2) + self.assert_metrics(num_metrics=1) def test_credential_removal(self): new_url = "http://username:password@mock/status/200" @@ -1291,6 +1452,7 @@ def test_basic_multiple(self): self.perform_request(self.URL, client=self.client) self.perform_request(self.URL, client=self.client2) self.assert_span(num_spans=2) + self.assert_metrics(num_metrics=1) def test_async_response_hook_does_nothing_if_not_coroutine(self): HTTPXClientInstrumentor().instrument(