feat(instrumentation-httpx): Add support for HTTP metrics (#3513)

* Add support for metrics

* Updated changelog

* Update package.py

* generate and lint

* Update

* Update

---------

Co-authored-by: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com>
Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com>
This commit is contained in:
Hector Hernandez 2025-06-03 07:00:38 -07:00 committed by GitHub
parent 701d65b022
commit ccf9cabeee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 491 additions and 8 deletions

View File

@ -60,8 +60,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#3012](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3012))
### Added
- `opentelemetry-instrumentation-aiohttp-client` Add support for HTTP metrics
([#3517](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3517))
([#3517](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3517))
- `opentelemetry-instrumentation-httpx` Add support for HTTP metrics
([#3513](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3513))
### Deprecated

View File

@ -25,7 +25,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 | 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

View File

@ -209,22 +209,32 @@ import logging
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 (
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
_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 +245,17 @@ from opentelemetry.instrumentation.utils import (
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 +367,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 +380,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 +433,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 +450,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 +471,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 +482,7 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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 +490,38 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
)
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.",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
)
self._request_hook = request_hook
self._response_hook = response_hook
@ -477,9 +556,11 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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 +577,15 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
_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 +596,7 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
# 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 +615,33 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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 +654,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 +665,7 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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 +673,40 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
)
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.",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
)
self._request_hook = request_hook
self._response_hook = response_hook
@ -600,9 +739,11 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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 +760,8 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
_inject_propagation_headers(headers, args, kwargs)
start_time = default_timer()
try:
response = await self._transport.handle_async_request(
*args, **kwargs
@ -626,6 +769,8 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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 +781,7 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
# 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 +801,34 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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 +851,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +860,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
``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,19 +880,46 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
)
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.",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
)
wrap_function_wrapper(
"httpx",
"HTTPTransport.handle_request",
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 +931,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +950,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +965,11 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +986,15 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
_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 +1005,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
# 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 +1022,33 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1058,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1073,11 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1094,15 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
_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 +1113,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
# 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 +1133,37 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
)
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 +1172,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1189,35 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
)
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.",
explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
)
if iscoroutinefunction(request_hook):
async_request_hook = request_hook
@ -946,6 +1240,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1255,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1270,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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 +1285,8 @@ class HTTPXClientInstrumentor(BaseInstrumentor):
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,

View File

@ -15,6 +15,6 @@
_instruments = ("httpx >= 0.18.0",)
_supports_metrics = False
_supports_metrics = True
_semconv_status = "migration"

View File

@ -26,6 +26,8 @@ from wrapt import ObjectProxy
import opentelemetry.instrumentation.httpx
from opentelemetry import trace
from opentelemetry.instrumentation._semconv import (
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
OTEL_SEMCONV_STABILITY_OPT_IN,
_OpenTelemetrySemanticConventionStability,
)
@ -67,6 +69,7 @@ if typing.TYPE_CHECKING:
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 +190,11 @@ class BaseTestCases:
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 +228,29 @@ class BaseTestCases:
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",
},
)
self.assertEqual(duration_data_point.count, 1)
self.assertTrue(duration_data_point.min >= 0)
self.assertTrue(duration_data_point.max >= 0)
self.assertTrue(duration_data_point.sum >= 0)
self.assertEqual(
duration_data_point.explicit_bounds,
HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
)
def test_nonstandard_http_method(self):
respx.route(method="NONSTANDARD").mock(
return_value=httpx.Response(405)
@ -243,6 +274,19 @@ class BaseTestCases:
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 +316,25 @@ class BaseTestCases:
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",
},
)
self.assertEqual(
duration_data_point.explicit_bounds,
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
)
def test_basic_new_semconv(self):
url = "http://mock:8080/status/200"
@ -313,6 +376,22 @@ class BaseTestCases:
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 +433,36 @@ class BaseTestCases:
span, opentelemetry.instrumentation.httpx
)
# Validate metrics
metrics = self.get_sorted_metrics()
self.assertEqual(len(metrics), 2)
# Old 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,
},
)
self.assertEqual(metrics[0].name, "http.client.duration")
# New 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,
},
)
self.assertEqual(metrics[1].name, "http.client.request.duration")
def test_basic_multiple(self):
self.perform_request(self.URL)
self.perform_request(self.URL)
@ -375,6 +484,16 @@ class BaseTestCases:
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 +514,17 @@ class BaseTestCases:
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 +547,30 @@ class BaseTestCases:
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 +738,7 @@ class BaseTestCases:
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 +778,18 @@ class BaseTestCases:
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 +1263,7 @@ class TestSyncIntegration(BaseTestCases.BaseManualTest):
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 +1272,7 @@ class TestSyncIntegration(BaseTestCases.BaseManualTest):
telemetry_transport = SyncOpenTelemetryTransport(
transport,
tracer_provider=tracer_provider,
meter_provider=meter_provider,
request_hook=request_hook,
response_hook=response_hook,
)
@ -1127,6 +1296,11 @@ class TestSyncIntegration(BaseTestCases.BaseManualTest):
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 +1322,7 @@ class TestAsyncIntegration(BaseTestCases.BaseManualTest):
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 +1331,7 @@ class TestAsyncIntegration(BaseTestCases.BaseManualTest):
telemetry_transport = AsyncOpenTelemetryTransport(
transport,
tracer_provider=tracer_provider,
meter_provider=meter_provider,
request_hook=request_hook,
response_hook=response_hook,
)
@ -1195,6 +1371,7 @@ class TestAsyncIntegration(BaseTestCases.BaseManualTest):
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 +1468,7 @@ class TestAsyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):
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(