From 653b450549d49556256ba5fe68f12afb91f2999a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 10:39:03 +0200 Subject: [PATCH 01/11] Add native OpenTelemetry client telemetry --- pyproject.toml | 1 + src/httpx2/httpx2/_client.py | 17 +- src/httpx2/httpx2/_opentelemetry.py | 342 ++++++++++++++++++++++++++++ src/httpx2/pyproject.toml | 3 + tests/httpx2/test_opentelemetry.py | 170 ++++++++++++++ uv.lock | 244 +++++++++++++++++++- 6 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 src/httpx2/httpx2/_opentelemetry.py create mode 100644 tests/httpx2/test_opentelemetry.py diff --git a/pyproject.toml b/pyproject.toml index 08859dbc..5b8ffcce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ # Packaging "build==1.3.0", "twine==6.1.0", + "logfire>=4.37.0", ] docs = ["zensical>=0.0.41", "mkdocstrings[python]>=0.27"] bench = [ diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 8d810bb6..180d0334 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -28,6 +28,7 @@ request_context, ) from ._models import Cookies, Headers, Request, Response +from ._opentelemetry import get_opentelemetry from ._sse import EventSource from ._status_codes import codes from ._transports.base import AsyncBaseTransport, BaseTransport @@ -1015,7 +1016,13 @@ def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send an async request with a sync Client instance.") with request_context(request=request): - response = transport.handle_request(request) + otel = get_opentelemetry() + if otel is None or not otel.is_enabled(request): + response = transport.handle_request(request) + else: + with otel.trace_request(request) as trace: + response = transport.handle_request(request) + trace.set_response(response) assert isinstance(response.stream, SyncByteStream) @@ -1761,7 +1768,13 @@ async def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send a sync request with an AsyncClient instance.") with request_context(request=request): - response = await transport.handle_async_request(request) + otel = get_opentelemetry() + if otel is None or not otel.is_enabled(request): + response = await transport.handle_async_request(request) + else: + with otel.trace_request(request) as trace: + response = await transport.handle_async_request(request) + trace.set_response(response) assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py new file mode 100644 index 00000000..863edd49 --- /dev/null +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import contextlib +import os +import re +import time +import typing +from functools import cache + +from .__version__ import __version__ +from ._models import Headers, Request, Response + +if typing.TYPE_CHECKING: # pragma: no cover + from collections.abc import Generator + +KNOWN_HTTP_METHODS = { + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "QUERY", + "TRACE", +} + +SEMCONV_SCHEMA_URL = "https://opentelemetry.io/schemas/1.42.0" +INSTRUMENTATION_NAME = "httpx2" +CLIENT_REQUEST_DURATION = "http.client.request.duration" +SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie"} +HTTP_CLIENT_REQUEST_DURATION_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10, +) + + +def get_opentelemetry() -> OpenTelemetry | None: + return _get_opentelemetry() + + +@cache +def _get_opentelemetry() -> OpenTelemetry | None: + try: + from opentelemetry import context, metrics, propagate, trace + from opentelemetry.trace import SpanKind, Status, StatusCode + except ImportError: + return None + + is_http_instrumentation_enabled: typing.Callable[[], bool] | None + try: + from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled + except ImportError: + is_http_instrumentation_enabled = None + + return OpenTelemetry( + context=context, + metrics=metrics, + propagate=propagate, + trace=trace, + span_kind=SpanKind, + status=Status, + status_code=StatusCode, + is_http_instrumentation_enabled=is_http_instrumentation_enabled, + ) + + +class OpenTelemetry: + def __init__( + self, + *, + context: typing.Any, + metrics: typing.Any, + propagate: typing.Any, + trace: typing.Any, + span_kind: typing.Any, + status: typing.Any, + status_code: typing.Any, + is_http_instrumentation_enabled: typing.Callable[[], bool] | None, + ) -> None: + self._context = context + self._propagate = propagate + self._span_kind = span_kind + self._status = status + self._status_code = status_code + self._is_http_instrumentation_enabled = is_http_instrumentation_enabled + self._suppress_instrumentation_key = getattr(context, "_SUPPRESS_INSTRUMENTATION_KEY", None) + self._suppress_http_instrumentation_key = getattr(context, "_SUPPRESS_HTTP_INSTRUMENTATION_KEY", None) + self._tracer = _get_tracer(trace) + meter = _get_meter(metrics) + self._duration_histogram = _create_duration_histogram(meter) + + def is_enabled(self, request: Request) -> bool: + if not _is_http_url(request): + return False + + if self._is_http_instrumentation_enabled is not None: + return self._is_http_instrumentation_enabled() + + if self._suppress_instrumentation_key is not None and self._context.get_value( + self._suppress_instrumentation_key + ): + return False + + if self._suppress_http_instrumentation_key is not None and self._context.get_value( + self._suppress_http_instrumentation_key + ): + return False + + return True + + @contextlib.contextmanager + def trace_request(self, request: Request) -> Generator[RequestTrace]: + span_attributes = _request_attributes(request) + metric_attributes = _request_metric_attributes(request) + span_name = _span_name(request.method) + span_cm = self._tracer.start_as_current_span( + span_name, + kind=self._span_kind.CLIENT, + attributes=span_attributes, + ) + span = span_cm.__enter__() + trace = RequestTrace( + span=span, + duration_histogram=self._duration_histogram, + metric_attributes=metric_attributes, + status=self._status, + status_code=self._status_code, + ) + start = time.perf_counter() + try: + self._propagate.inject(request.headers) + yield trace + except BaseException as exc: + trace.set_exception(exc) + raise + finally: + trace.record_duration(time.perf_counter() - start) + span_cm.__exit__(*trace.exc_info) + + +class RequestTrace: + def __init__( + self, + *, + span: typing.Any, + duration_histogram: typing.Any, + metric_attributes: dict[str, typing.Any], + status: typing.Any, + status_code: typing.Any, + ) -> None: + self._span = span + self._duration_histogram = duration_histogram + self._metric_attributes = metric_attributes + self._status = status + self._status_code = status_code + self.exc_info: tuple[type[BaseException] | None, BaseException | None, typing.Any] = (None, None, None) + + def set_response(self, response: Response) -> None: + response_attributes = _response_attributes(response) + self._metric_attributes.update(_response_metric_attributes(response)) + _set_attributes(self._span, response_attributes) + + if _is_error_status(response.status_code): + self._set_error(str(response.status_code)) + + def set_exception(self, exc: BaseException) -> None: + self.exc_info = (type(exc), exc, exc.__traceback__) + self._set_error(f"{type(exc).__module__}.{type(exc).__qualname__}") + + def record_duration(self, duration: float) -> None: + self._duration_histogram.record(max(duration, 0), attributes=self._metric_attributes) + + def _set_error(self, error_type: str) -> None: + self._metric_attributes["error.type"] = error_type + if self._span.is_recording(): + self._span.set_attribute("error.type", error_type) + self._span.set_status(self._status(self._status_code.ERROR)) + + +def _request_attributes(request: Request) -> dict[str, typing.Any]: + attributes = _request_metric_attributes(request) + attributes["url.full"] = _redact_url(request) + + captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST") + if captured_headers: + attributes.update(_header_attributes("http.request.header", request.headers, captured_headers)) + + return attributes + + +def _request_metric_attributes(request: Request) -> dict[str, typing.Any]: + method = _known_method(request.method) + attributes: dict[str, typing.Any] = { + "http.request.method": method, + } + + if method == "_OTHER": + attributes["http.request.method_original"] = request.method + + if request.url.host: + attributes["server.address"] = request.url.host + + port = request.url.port or {"http": 80, "https": 443}.get(request.url.scheme) + if port is not None: + attributes["server.port"] = port + + return attributes + + +def _response_attributes(response: Response) -> dict[str, typing.Any]: + attributes = _response_metric_attributes(response) + + captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE") + if captured_headers: + attributes.update(_header_attributes("http.response.header", response.headers, captured_headers)) + + return attributes + + +def _response_metric_attributes(response: Response) -> dict[str, typing.Any]: + attributes: dict[str, typing.Any] = {"http.response.status_code": response.status_code} + + if response.http_version: + attributes["network.protocol.version"] = response.http_version.removeprefix("HTTP/") + + return attributes + + +def _is_http_url(request: Request) -> bool: + return request.url.scheme in {"http", "https"} + + +def _span_name(method: str) -> str: + method = _known_method(method) + return "HTTP" if method == "_OTHER" else method + + +def _known_method(method: str) -> str: + return method if method in KNOWN_HTTP_METHODS else "_OTHER" + + +def _redact_url(request: Request) -> str: + if request.url.userinfo: + return str(request.url.copy_with(username="REDACTED", password="REDACTED")) + return str(request.url) + + +def _is_error_status(status_code: int) -> bool: + return status_code >= 400 + + +def _set_attributes(span: typing.Any, attributes: dict[str, typing.Any]) -> None: + if not span.is_recording(): + return + + for name, value in attributes.items(): + span.set_attribute(name, value) + + +def _captured_headers(name: str) -> list[re.Pattern[str]]: + value = os.environ.get(name, "") + return [re.compile(pattern.strip(), re.IGNORECASE) for pattern in value.split(",") if pattern.strip()] + + +def _header_attributes( + prefix: str, + headers: Headers, + captured_headers: list[re.Pattern[str]], +) -> dict[str, list[str]]: + sensitive_headers = _sensitive_headers() + attributes: dict[str, list[str]] = {} + for key in headers.keys(): + if not any(pattern.fullmatch(key) for pattern in captured_headers): + continue + attribute = f"{prefix}.{key.lower().replace('-', '_')}" + values = headers.get_list(key, split_commas=True) + attributes[attribute] = [ + "[REDACTED]" if _is_sensitive_header(key, sensitive_headers) else value for value in values + ] + return attributes + + +def _sensitive_headers() -> list[re.Pattern[str]]: + value = os.environ.get("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", "") + names = [*SENSITIVE_HEADERS, *[item.strip() for item in value.split(",") if item.strip()]] + return [re.compile(name, re.IGNORECASE) for name in names] + + +def _is_sensitive_header(key: str, sensitive_headers: list[re.Pattern[str]]) -> bool: + return any(pattern.fullmatch(key) for pattern in sensitive_headers) + + +def _get_tracer(trace: typing.Any) -> typing.Any: + try: + return trace.get_tracer( + INSTRUMENTATION_NAME, + instrumenting_library_version=__version__, + schema_url=SEMCONV_SCHEMA_URL, + ) + except TypeError: + return trace.get_tracer(INSTRUMENTATION_NAME, __version__) + + +def _get_meter(metrics: typing.Any) -> typing.Any: + try: + return metrics.get_meter( + INSTRUMENTATION_NAME, + instrumenting_library_version=__version__, + schema_url=SEMCONV_SCHEMA_URL, + ) + except TypeError: + return metrics.get_meter(INSTRUMENTATION_NAME, __version__) + + +def _create_duration_histogram(meter: typing.Any) -> typing.Any: + try: + return meter.create_histogram( + name=CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + explicit_bucket_boundaries_advisory=HTTP_CLIENT_REQUEST_DURATION_BUCKETS, + ) + except TypeError: + return meter.create_histogram( + name=CLIENT_REQUEST_DURATION, + unit="s", + description="Duration of HTTP client requests.", + ) diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index dc194f7f..7798401f 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -64,6 +64,9 @@ cli = [ http2 = [ "h2>=3,<5", ] +opentelemetry = [ + "opentelemetry-api>=1.42.0", +] socks = [ "socksio==1.*", ] diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py new file mode 100644 index 00000000..91eac5ed --- /dev/null +++ b/tests/httpx2/test_opentelemetry.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import typing + +import logfire +import pytest +from logfire.testing import CaptureLogfire +from opentelemetry.trace import SpanKind, StatusCode + +import httpx2 +import httpx2._opentelemetry as otel_module + + +@pytest.fixture(autouse=True) +def clear_opentelemetry_cache() -> typing.Iterator[None]: + otel_module._get_opentelemetry.cache_clear() + yield + otel_module._get_opentelemetry.cache_clear() + + +def test_sync_client_emits_current_http_client_span_and_duration_metric(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["traceparent"].startswith("00-") + return httpx2.Response( + 404, + headers={"content-type": "text/plain"}, + extensions={"http_version": b"HTTP/2"}, + ) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + response = client.get("https://user:password@example.com:8443/example") + + assert response.status_code == 404 + [span] = _httpx2_spans(capfire) + assert span.name == "GET" + assert span.kind is SpanKind.CLIENT + assert span.status.status_code is StatusCode.ERROR + _assert_attributes_include( + span.attributes, + { + "http.request.method": "GET", + "url.full": "https://REDACTED:REDACTED@example.com:8443/example", + "server.address": "example.com", + "server.port": 8443, + "http.response.status_code": 404, + "network.protocol.version": "2", + "error.type": "404", + }, + ) + assert "http.method" not in span.attributes + assert "http.status_code" not in span.attributes + + metric = _duration_metric(capfire) + assert metric["name"] == "http.client.request.duration" + [data_point] = metric["data"]["data_points"] + assert data_point["count"] == 1 + assert data_point["sum"] >= 0 + assert data_point["attributes"] == { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 8443, + "http.response.status_code": 404, + "network.protocol.version": "2", + "error.type": "404", + } + + +@pytest.mark.anyio +async def test_async_client_emits_opentelemetry(capfire: CaptureLogfire) -> None: + async def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers["traceparent"].startswith("00-") + return httpx2.Response(204) + + transport = httpx2.MockTransport(handler) + async with httpx2.AsyncClient(transport=transport) as client: + response = await client.get("https://example.com/") + + assert response.status_code == 204 + [span] = _httpx2_spans(capfire) + assert span.kind is SpanKind.CLIENT + assert span.attributes["http.response.status_code"] == 204 + assert _duration_metric(capfire)["data"]["data_points"] + + +def test_opentelemetry_honors_logfire_suppression_context(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert "traceparent" not in request.headers + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with logfire.suppress_instrumentation(): + with httpx2.Client(transport=transport) as client: + response = client.get("https://example.com/") + + assert response.status_code == 200 + assert _httpx2_spans(capfire) == [] + assert _duration_metrics(capfire) == [] + + +def test_opentelemetry_records_exceptions(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + raise httpx2.ConnectError("no route") + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(httpx2.ConnectError): + client.get("https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.attributes["error.type"] == "httpx2.ConnectError" + assert span.status.status_code is StatusCode.ERROR + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" + + +def test_opentelemetry_captures_and_sanitizes_opt_in_headers( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST", "x-private,x-.*") + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE", "x-response-private") + monkeypatch.setenv( + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", + "x-private,x-response-private", + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, headers={"x-response-private": "secret"}) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport, headers={"x-private": "secret", "x-request-id": "abc"}) as client: + client.get("https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.attributes["http.request.header.x_private"] == ("[REDACTED]",) + assert span.attributes["http.request.header.x_request_id"] == ("abc",) + assert span.attributes["http.response.header.x_response_private"] == ("[REDACTED]",) + assert "http.request.header.x_private" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + + +def _duration_metric(capfire: CaptureLogfire) -> dict[str, typing.Any]: + [metric] = _duration_metrics(capfire) + return metric + + +def _duration_metrics(capfire: CaptureLogfire) -> list[dict[str, typing.Any]]: + try: + metrics = typing.cast(typing.Any, capfire).get_collected_metrics() + except AttributeError: + return [] + return [metric for metric in metrics if metric["name"] == "http.client.request.duration"] + + +def _httpx2_spans(capfire: CaptureLogfire) -> list[typing.Any]: + return [ + span + for span in capfire.exporter.exported_spans + if span.instrumentation_scope is not None + and span.instrumentation_scope.name == "httpx2" + and span.attributes is not None + and span.attributes.get("logfire.span_type") == "span" + ] + + +def _assert_attributes_include( + attributes: typing.Mapping[str, typing.Any], + expected: dict[str, typing.Any], +) -> None: + for key, value in expected.items(): + assert attributes[key] == value diff --git a/uv.lock b/uv.lock index ef708e99..1ded4466 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,7 @@ dev = [ { name = "cryptography", specifier = "==46.0.7" }, { name = "httpcore2", extras = ["asyncio", "trio", "http2", "socks"], editable = "src/httpcore2" }, { name = "httpx2", extras = ["brotli", "cli", "http2", "socks", "zstd"], editable = "src/httpx2" }, + { name = "logfire", specifier = ">=4.37.0" }, { name = "mypy", specifier = "==1.17.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-codspeed", specifier = ">=4.1.1" }, @@ -934,6 +935,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "flasgger" version = "0.9.7.1" @@ -1155,6 +1165,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "greenlet" version = "2.0.2" @@ -1369,6 +1391,9 @@ cli = [ http2 = [ { name = "h2" }, ] +opentelemetry = [ + { name = "opentelemetry-api" }, +] socks = [ { name = "socksio" }, ] @@ -1385,6 +1410,7 @@ requires-dist = [ { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "httpcore2", editable = "src/httpcore2" }, { name = "idna", specifier = ">=3.18" }, + { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.42.0" }, { name = "pygments", marker = "extra == 'cli'", specifier = "==2.*" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=10,<16" }, { name = "socksio", marker = "extra == 'socks'", specifier = "==1.*" }, @@ -1392,7 +1418,7 @@ requires-dist = [ { name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.5.0" }, { name = "zstandard", marker = "python_full_version < '3.14' and extra == 'zstd'", specifier = ">=0.18.0" }, ] -provides-extras = ["brotli", "cli", "http2", "socks", "zstd"] +provides-extras = ["brotli", "cli", "http2", "opentelemetry", "socks", "zstd"] [[package]] name = "hyperframe" @@ -1680,6 +1706,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "logfire" +version = "4.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/f2/34b8ebbd6bbd82c71055d6b881b24d8ada79a0e6692d3dd8cca5e86fadb3/logfire-4.37.0.tar.gz", hash = "sha256:7ee0cb64b59c356a41a1701fb84597037f8db1fa15df7a3715ef363e5a1de06a", size = 1212176, upload-time = "2026-06-12T20:47:06.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/08/1805d2f26955671115aae555d78cc4c72a6fe733f332d44d69756bc1737b/logfire-4.37.0-py3-none-any.whl", hash = "sha256:a20823e6dbb3204614a3ea5e79c91df42405c5112393ec9d8e34ef45b60d315f", size = 378930, upload-time = "2026-06-12T20:47:03.674Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -2364,6 +2409,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -2624,6 +2765,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -3382,6 +3538,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" version = "1.23.0" From 20845c21ff44836927f8608262c412266d586943 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 11:11:53 +0200 Subject: [PATCH 02/11] Address OpenTelemetry review feedback --- src/httpx2/httpx2/_client.py | 28 ++++++- src/httpx2/httpx2/_opentelemetry.py | 120 ++++++++++++++++++++++------ tests/httpx2/test_opentelemetry.py | 110 +++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 26 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 180d0334..478e0bdb 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -1020,9 +1020,21 @@ def _send_single_request(self, request: Request) -> Response: if otel is None or not otel.is_enabled(request): response = transport.handle_request(request) else: - with otel.trace_request(request) as trace: + trace = otel.start_request(request) + try: response = transport.handle_request(request) trace.set_response(response) + except BaseException as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + trace.close() + raise + trace.detach_current() + if response.is_closed: + trace.close() + else: + assert isinstance(response.stream, SyncByteStream) + response.stream = trace.wrap_sync_stream(response.stream) assert isinstance(response.stream, SyncByteStream) @@ -1772,9 +1784,21 @@ async def _send_single_request(self, request: Request) -> Response: if otel is None or not otel.is_enabled(request): response = await transport.handle_async_request(request) else: - with otel.trace_request(request) as trace: + trace = otel.start_request(request) + try: response = await transport.handle_async_request(request) trace.set_response(response) + except BaseException as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + trace.close() + raise + trace.detach_current() + if response.is_closed: + trace.close() + else: + assert isinstance(response.stream, AsyncByteStream) + response.stream = trace.wrap_async_stream(response.stream) assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index 863edd49..ba1c0889 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib import os import re import time @@ -9,9 +8,7 @@ from .__version__ import __version__ from ._models import Headers, Request, Response - -if typing.TYPE_CHECKING: # pragma: no cover - from collections.abc import Generator +from ._types import AsyncByteStream, SyncByteStream KNOWN_HTTP_METHODS = { "CONNECT", @@ -57,13 +54,13 @@ def _get_opentelemetry() -> OpenTelemetry | None: try: from opentelemetry import context, metrics, propagate, trace from opentelemetry.trace import SpanKind, Status, StatusCode - except ImportError: + except ImportError: # pragma: no cover return None is_http_instrumentation_enabled: typing.Callable[[], bool] | None try: from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled - except ImportError: + except ImportError: # pragma: no cover is_http_instrumentation_enabled = None return OpenTelemetry( @@ -93,6 +90,7 @@ def __init__( ) -> None: self._context = context self._propagate = propagate + self._trace = trace self._span_kind = span_kind self._status = status self._status_code = status_code @@ -122,8 +120,7 @@ def is_enabled(self, request: Request) -> bool: return True - @contextlib.contextmanager - def trace_request(self, request: Request) -> Generator[RequestTrace]: + def start_request(self, request: Request) -> RequestTrace: span_attributes = _request_attributes(request) metric_attributes = _request_metric_attributes(request) span_name = _span_name(request.method) @@ -131,6 +128,7 @@ def trace_request(self, request: Request) -> Generator[RequestTrace]: span_name, kind=self._span_kind.CLIENT, attributes=span_attributes, + end_on_exit=False, ) span = span_cm.__enter__() trace = RequestTrace( @@ -139,17 +137,11 @@ def trace_request(self, request: Request) -> Generator[RequestTrace]: metric_attributes=metric_attributes, status=self._status, status_code=self._status_code, + span_context_manager=span_cm, + start=time.perf_counter(), ) - start = time.perf_counter() - try: - self._propagate.inject(request.headers) - yield trace - except BaseException as exc: - trace.set_exception(exc) - raise - finally: - trace.record_duration(time.perf_counter() - start) - span_cm.__exit__(*trace.exc_info) + self._propagate.inject(request.headers) + return trace class RequestTrace: @@ -161,13 +153,18 @@ def __init__( metric_attributes: dict[str, typing.Any], status: typing.Any, status_code: typing.Any, + span_context_manager: typing.Any, + start: float, ) -> None: self._span = span self._duration_histogram = duration_histogram self._metric_attributes = metric_attributes self._status = status self._status_code = status_code - self.exc_info: tuple[type[BaseException] | None, BaseException | None, typing.Any] = (None, None, None) + self._span_context_manager = span_context_manager + self._start = start + self._closed = False + self._detached = False def set_response(self, response: Response) -> None: response_attributes = _response_attributes(response) @@ -178,12 +175,40 @@ def set_response(self, response: Response) -> None: self._set_error(str(response.status_code)) def set_exception(self, exc: BaseException) -> None: - self.exc_info = (type(exc), exc, exc.__traceback__) + if self._span.is_recording(): + self._span.record_exception(exc) self._set_error(f"{type(exc).__module__}.{type(exc).__qualname__}") def record_duration(self, duration: float) -> None: self._duration_histogram.record(max(duration, 0), attributes=self._metric_attributes) + def close(self) -> None: + if self._closed: # pragma: no cover + return + + self._closed = True + self.detach_current() + self.record_duration(time.perf_counter() - self._start) + self._span.end() + + def detach_current( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: typing.Any = None, + ) -> None: + if self._detached: + return + + self._detached = True + self._span_context_manager.__exit__(exc_type, exc_value, traceback) + + def wrap_sync_stream(self, stream: SyncByteStream) -> SyncByteStream: + return OpenTelemetrySyncStream(stream=stream, trace=self) + + def wrap_async_stream(self, stream: AsyncByteStream) -> AsyncByteStream: + return OpenTelemetryAsyncStream(stream=stream, trace=self) + def _set_error(self, error_type: str) -> None: self._metric_attributes["error.type"] = error_type if self._span.is_recording(): @@ -191,6 +216,45 @@ def _set_error(self, error_type: str) -> None: self._span.set_status(self._status(self._status_code.ERROR)) +class OpenTelemetrySyncStream(SyncByteStream): + def __init__(self, *, stream: SyncByteStream, trace: RequestTrace) -> None: + self._stream = stream + self._trace = trace + + def __iter__(self) -> typing.Iterator[bytes]: + try: + yield from self._stream + except BaseException as exc: + self._trace.set_exception(exc) + raise + + def close(self) -> None: + try: + self._stream.close() + finally: + self._trace.close() + + +class OpenTelemetryAsyncStream(AsyncByteStream): + def __init__(self, *, stream: AsyncByteStream, trace: RequestTrace) -> None: + self._stream = stream + self._trace = trace + + async def __aiter__(self) -> typing.AsyncIterator[bytes]: + try: + async for chunk in self._stream: + yield chunk + except BaseException as exc: + self._trace.set_exception(exc) + raise + + async def aclose(self) -> None: + try: + await self._stream.aclose() + finally: + self._trace.close() + + def _request_attributes(request: Request) -> dict[str, typing.Any]: attributes = _request_metric_attributes(request) attributes["url.full"] = _redact_url(request) @@ -250,7 +314,15 @@ def _span_name(method: str) -> str: def _known_method(method: str) -> str: - return method if method in KNOWN_HTTP_METHODS else "_OTHER" + return method if method in _known_methods() else "_OTHER" + + +def _known_methods() -> set[str]: + configured_methods = os.environ.get("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS") + if configured_methods is None: + return KNOWN_HTTP_METHODS + + return {method.strip().upper() for method in configured_methods.split(",") if method.strip()} def _redact_url(request: Request) -> str: @@ -311,7 +383,7 @@ def _get_tracer(trace: typing.Any) -> typing.Any: instrumenting_library_version=__version__, schema_url=SEMCONV_SCHEMA_URL, ) - except TypeError: + except TypeError: # pragma: no cover return trace.get_tracer(INSTRUMENTATION_NAME, __version__) @@ -322,7 +394,7 @@ def _get_meter(metrics: typing.Any) -> typing.Any: instrumenting_library_version=__version__, schema_url=SEMCONV_SCHEMA_URL, ) - except TypeError: + except TypeError: # pragma: no cover return metrics.get_meter(INSTRUMENTATION_NAME, __version__) @@ -334,7 +406,7 @@ def _create_duration_histogram(meter: typing.Any) -> typing.Any: description="Duration of HTTP client requests.", explicit_bucket_boundaries_advisory=HTTP_CLIENT_REQUEST_DURATION_BUCKETS, ) - except TypeError: + except TypeError: # pragma: no cover return meter.create_histogram( name=CLIENT_REQUEST_DURATION, unit="s", diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index 91eac5ed..a3218595 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -11,6 +11,18 @@ import httpx2._opentelemetry as otel_module +class FailingSyncStream(httpx2.SyncByteStream): + def __iter__(self) -> typing.Iterator[bytes]: + yield b"partial" + raise RuntimeError("stream failed") + + +class FailingAsyncStream(httpx2.AsyncByteStream): + async def __aiter__(self) -> typing.AsyncIterator[bytes]: + yield b"partial" + raise RuntimeError("stream failed") + + @pytest.fixture(autouse=True) def clear_opentelemetry_cache() -> typing.Iterator[None]: otel_module._get_opentelemetry.cache_clear() @@ -98,6 +110,36 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metrics(capfire) == [] +def test_opentelemetry_honors_context_suppression_fallback(capfire: CaptureLogfire) -> None: + otel = otel_module.get_opentelemetry() + assert otel is not None + otel._is_http_instrumentation_enabled = None + + request = httpx2.Request("GET", "https://example.com/") + assert otel.is_enabled(request) is True + + with logfire.suppress_instrumentation(): + assert otel.is_enabled(request) is False + + assert capfire.exporter.exported_spans == [] + + +def test_opentelemetry_honors_http_context_suppression_fallback(capfire: CaptureLogfire) -> None: + otel = otel_module.get_opentelemetry() + assert otel is not None + otel._is_http_instrumentation_enabled = None + otel._suppress_http_instrumentation_key = "httpx2.suppress_http_instrumentation" + + request = httpx2.Request("GET", "https://example.com/") + token = otel._context.attach(otel._context.set_value(otel._suppress_http_instrumentation_key, True)) + try: + assert otel.is_enabled(request) is False + finally: + otel._context.detach(token) + + assert capfire.exporter.exported_spans == [] + + def test_opentelemetry_records_exceptions(capfire: CaptureLogfire) -> None: def handler(request: httpx2.Request) -> httpx2.Response: raise httpx2.ConnectError("no route") @@ -113,6 +155,74 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" +def test_opentelemetry_records_sync_body_read_exceptions(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, stream=FailingSyncStream()) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(RuntimeError, match="stream failed"): + client.get("https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.attributes["error.type"] == "builtins.RuntimeError" + assert span.status.status_code is StatusCode.ERROR + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" + + +@pytest.mark.anyio +async def test_opentelemetry_records_async_body_read_exceptions(capfire: CaptureLogfire) -> None: + async def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, stream=FailingAsyncStream()) + + transport = httpx2.MockTransport(handler) + async with httpx2.AsyncClient(transport=transport) as client: + with pytest.raises(RuntimeError, match="stream failed"): + await client.get("https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.attributes["error.type"] == "builtins.RuntimeError" + assert span.status.status_code is StatusCode.ERROR + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" + + +def test_opentelemetry_honors_configured_known_methods( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS", "GET,PROPFIND") + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + client.request("PROPFIND", "https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.name == "PROPFIND" + assert span.attributes["http.request.method"] == "PROPFIND" + assert "http.request.method_original" not in span.attributes + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["http.request.method"] == "PROPFIND" + + +def test_opentelemetry_uses_other_for_unknown_methods(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + client.request("BREW", "https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.name == "HTTP" + assert span.attributes["http.request.method"] == "_OTHER" + assert span.attributes["http.request.method_original"] == "BREW" + metric_attributes = _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + assert metric_attributes["http.request.method"] == "_OTHER" + assert metric_attributes["http.request.method_original"] == "BREW" + + def test_opentelemetry_captures_and_sanitizes_opt_in_headers( capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch, From a950e2dc651dcf1c5dc2fadfe0d1cbf05343f6dd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 11:17:18 +0200 Subject: [PATCH 03/11] Handle OpenTelemetry propagation failures --- src/httpx2/httpx2/_opentelemetry.py | 8 +++++++- tests/httpx2/test_opentelemetry.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index ba1c0889..ba51d5d5 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -140,7 +140,13 @@ def start_request(self, request: Request) -> RequestTrace: span_context_manager=span_cm, start=time.perf_counter(), ) - self._propagate.inject(request.headers) + try: + self._propagate.inject(request.headers) + except BaseException as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + trace.close() + raise return trace diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index a3218595..a86d83da 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -155,6 +155,32 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" +def test_opentelemetry_records_propagation_injection_exceptions( + capfire: CaptureLogfire, + monkeypatch: pytest.MonkeyPatch, +) -> None: + otel = otel_module.get_opentelemetry() + assert otel is not None + + def inject(headers: httpx2.Headers) -> None: + raise RuntimeError("inject failed") + + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("transport should not be called") # pragma: no cover + + monkeypatch.setattr(otel._propagate, "inject", inject) + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(RuntimeError, match="inject failed"): + client.get("https://example.com/") + + [span] = _httpx2_spans(capfire) + assert span.attributes["error.type"] == "builtins.RuntimeError" + assert span.status.status_code is StatusCode.ERROR + assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" + + def test_opentelemetry_records_sync_body_read_exceptions(capfire: CaptureLogfire) -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, stream=FailingSyncStream()) From 28cde9769360bf4d4f1ada7f5f19858f1e8f4e07 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 11:50:03 +0200 Subject: [PATCH 04/11] Use inline snapshots for OpenTelemetry spans --- pyproject.toml | 1 + tests/httpx2/test_opentelemetry.py | 350 +++++++++++++++++++++++------ uv.lock | 27 +++ 3 files changed, 311 insertions(+), 67 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b8ffcce..b54c0e55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "chardet==6.0.0.post1", "coverage[toml]==7.10.6", "cryptography==46.0.7", + "inline-snapshot>=0.34.2", "pytest>=9.0.3", "pytest-codspeed>=4.1.1", "pytest-httpbin==2.0.0", diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index a86d83da..3e7ec41d 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -4,8 +4,8 @@ import logfire import pytest +from inline_snapshot import snapshot from logfire.testing import CaptureLogfire -from opentelemetry.trace import SpanKind, StatusCode import httpx2 import httpx2._opentelemetry as otel_module @@ -44,24 +44,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: response = client.get("https://user:password@example.com:8443/example") assert response.status_code == 404 - [span] = _httpx2_spans(capfire) - assert span.name == "GET" - assert span.kind is SpanKind.CLIENT - assert span.status.status_code is StatusCode.ERROR - _assert_attributes_include( - span.attributes, - { - "http.request.method": "GET", - "url.full": "https://REDACTED:REDACTED@example.com:8443/example", - "server.address": "example.com", - "server.port": 8443, - "http.response.status_code": 404, - "network.protocol.version": "2", - "error.type": "404", - }, + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 8443, + "url.full": "https://REDACTED:REDACTED@example.com:8443/example", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 404, + "network.protocol.version": "2", + "error.type": "404", + "logfire.level_num": 17, + }, + } + ] ) - assert "http.method" not in span.attributes - assert "http.status_code" not in span.attributes metric = _duration_metric(capfire) assert metric["name"] == "http.client.request.duration" @@ -89,9 +95,28 @@ async def handler(request: httpx2.Request) -> httpx2.Response: response = await client.get("https://example.com/") assert response.status_code == 204 - [span] = _httpx2_spans(capfire) - assert span.kind is SpanKind.CLIENT - assert span.attributes["http.response.status_code"] == 204 + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 204, + "network.protocol.version": "1.1", + }, + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"] @@ -106,7 +131,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: response = client.get("https://example.com/") assert response.status_code == 200 - assert _httpx2_spans(capfire) == [] + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) assert _duration_metrics(capfire) == [] @@ -121,7 +146,7 @@ def test_opentelemetry_honors_context_suppression_fallback(capfire: CaptureLogfi with logfire.suppress_instrumentation(): assert otel.is_enabled(request) is False - assert capfire.exporter.exported_spans == [] + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) def test_opentelemetry_honors_http_context_suppression_fallback(capfire: CaptureLogfire) -> None: @@ -137,7 +162,7 @@ def test_opentelemetry_honors_http_context_suppression_fallback(capfire: Capture finally: otel._context.detach(token) - assert capfire.exporter.exported_spans == [] + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) def test_opentelemetry_records_exceptions(capfire: CaptureLogfire) -> None: @@ -149,9 +174,51 @@ def handler(request: httpx2.Request) -> httpx2.Response: with pytest.raises(httpx2.ConnectError): client.get("https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.attributes["error.type"] == "httpx2.ConnectError" - assert span.status.status_code is StatusCode.ERROR + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 4000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "error.type": "httpx2.ConnectError", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "httpx2.ConnectError", + "exception.message": "no route", + "exception.stacktrace": "httpx2.ConnectError: no route", + "exception.escaped": "False", + }, + }, + { + "name": "exception", + "timestamp": 3000000000, + "attributes": { + "exception.type": "httpx2.ConnectError", + "exception.message": "no route", + "exception.stacktrace": "httpx2.ConnectError: no route", + "exception.escaped": "False", + }, + }, + ], + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" @@ -175,9 +242,51 @@ def handler(request: httpx2.Request) -> httpx2.Response: with pytest.raises(RuntimeError, match="inject failed"): client.get("https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.attributes["error.type"] == "builtins.RuntimeError" - assert span.status.status_code is StatusCode.ERROR + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 4000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "error.type": "builtins.RuntimeError", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "inject failed", + "exception.stacktrace": "RuntimeError: inject failed", + "exception.escaped": "False", + }, + }, + { + "name": "exception", + "timestamp": 3000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "inject failed", + "exception.stacktrace": "RuntimeError: inject failed", + "exception.escaped": "False", + }, + }, + ], + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" @@ -190,9 +299,43 @@ def handler(request: httpx2.Request) -> httpx2.Response: with pytest.raises(RuntimeError, match="stream failed"): client.get("https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.attributes["error.type"] == "builtins.RuntimeError" - assert span.status.status_code is StatusCode.ERROR + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 3000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "error.type": "builtins.RuntimeError", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "stream failed", + "exception.stacktrace": "RuntimeError: stream failed", + "exception.escaped": "False", + }, + } + ], + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" @@ -206,9 +349,43 @@ async def handler(request: httpx2.Request) -> httpx2.Response: with pytest.raises(RuntimeError, match="stream failed"): await client.get("https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.attributes["error.type"] == "builtins.RuntimeError" - assert span.status.status_code is StatusCode.ERROR + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 3000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "error.type": "builtins.RuntimeError", + "logfire.level_num": 17, + }, + "events": [ + { + "name": "exception", + "timestamp": 2000000000, + "attributes": { + "exception.type": "RuntimeError", + "exception.message": "stream failed", + "exception.stacktrace": "RuntimeError: stream failed", + "exception.escaped": "False", + }, + } + ], + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" @@ -225,10 +402,28 @@ def handler(request: httpx2.Request) -> httpx2.Response: with httpx2.Client(transport=transport) as client: client.request("PROPFIND", "https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.name == "PROPFIND" - assert span.attributes["http.request.method"] == "PROPFIND" - assert "http.request.method_original" not in span.attributes + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "PROPFIND", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "PROPFIND", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "PROPFIND", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + }, + } + ] + ) assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["http.request.method"] == "PROPFIND" @@ -240,10 +435,29 @@ def handler(request: httpx2.Request) -> httpx2.Response: with httpx2.Client(transport=transport) as client: client.request("BREW", "https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.name == "HTTP" - assert span.attributes["http.request.method"] == "_OTHER" - assert span.attributes["http.request.method_original"] == "BREW" + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "HTTP", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "_OTHER", + "http.request.method_original": "BREW", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "HTTP", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + }, + } + ] + ) metric_attributes = _duration_metric(capfire)["data"]["data_points"][0]["attributes"] assert metric_attributes["http.request.method"] == "_OTHER" assert metric_attributes["http.request.method_original"] == "BREW" @@ -267,10 +481,31 @@ def handler(request: httpx2.Request) -> httpx2.Response: with httpx2.Client(transport=transport, headers={"x-private": "secret", "x-request-id": "abc"}) as client: client.get("https://example.com/") - [span] = _httpx2_spans(capfire) - assert span.attributes["http.request.header.x_private"] == ("[REDACTED]",) - assert span.attributes["http.request.header.x_request_id"] == ("abc",) - assert span.attributes["http.response.header.x_response_private"] == ("[REDACTED]",) + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "http.request.header.x_private": ("[REDACTED]",), + "http.request.header.x_request_id": ("abc",), + "logfire.span_type": "span", + "logfire.msg": "GET", + "http.response.status_code": 200, + "network.protocol.version": "1.1", + "http.response.header.x_response_private": ("[REDACTED]",), + }, + } + ] + ) assert "http.request.header.x_private" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] @@ -285,22 +520,3 @@ def _duration_metrics(capfire: CaptureLogfire) -> list[dict[str, typing.Any]]: except AttributeError: return [] return [metric for metric in metrics if metric["name"] == "http.client.request.duration"] - - -def _httpx2_spans(capfire: CaptureLogfire) -> list[typing.Any]: - return [ - span - for span in capfire.exporter.exported_spans - if span.instrumentation_scope is not None - and span.instrumentation_scope.name == "httpx2" - and span.attributes is not None - and span.attributes.get("logfire.span_type") == "span" - ] - - -def _assert_attributes_include( - attributes: typing.Mapping[str, typing.Any], - expected: dict[str, typing.Any], -) -> None: - for key, value in expected.items(): - assert attributes[key] == value diff --git a/uv.lock b/uv.lock index 1ded4466..b6b62643 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,7 @@ dev = [ { name = "cryptography", specifier = "==46.0.7" }, { name = "httpcore2", extras = ["asyncio", "trio", "http2", "socks"], editable = "src/httpcore2" }, { name = "httpx2", extras = ["brotli", "cli", "http2", "socks", "zstd"], editable = "src/httpx2" }, + { name = "inline-snapshot", specifier = ">=0.34.2" }, { name = "logfire", specifier = ">=4.37.0" }, { name = "mypy", specifier = "==1.17.1" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -210,6 +211,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "async-generator" version = "1.10" @@ -1471,6 +1481,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inline-snapshot" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/c3/b5c36ab59e355b8d7c59205b8903fa74458c6938c9691f7d7730404f94c9/inline_snapshot-0.34.2.tar.gz", hash = "sha256:d160cb6059e00916c2e846abc014ead6f01fb24479f13696fb8670d7a7937f67", size = 2641142, upload-time = "2026-06-19T21:17:27.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2a/84fe7ef052ac666e95e7cabdf0905aa3f2c90b5a117419760b39b577602a/inline_snapshot-0.34.2-py3-none-any.whl", hash = "sha256:743a514a08ffd0d2d62878b0208d4def5041585affa7db6b89f0ca2e23552361", size = 90717, upload-time = "2026-06-19T21:17:25.629Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" From 7a7fea73eb8a4d7cfb34ce8269b80ada2e1c1c06 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 13:40:55 +0200 Subject: [PATCH 05/11] Simplify OpenTelemetry response handling --- src/httpx2/httpx2/_client.py | 12 +-- src/httpx2/httpx2/_opentelemetry.py | 46 ------------ tests/httpx2/test_opentelemetry.py | 111 ---------------------------- 3 files changed, 2 insertions(+), 167 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 478e0bdb..c9dffd18 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -1030,11 +1030,7 @@ def _send_single_request(self, request: Request) -> Response: trace.close() raise trace.detach_current() - if response.is_closed: - trace.close() - else: - assert isinstance(response.stream, SyncByteStream) - response.stream = trace.wrap_sync_stream(response.stream) + trace.close() assert isinstance(response.stream, SyncByteStream) @@ -1794,11 +1790,7 @@ async def _send_single_request(self, request: Request) -> Response: trace.close() raise trace.detach_current() - if response.is_closed: - trace.close() - else: - assert isinstance(response.stream, AsyncByteStream) - response.stream = trace.wrap_async_stream(response.stream) + trace.close() assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index ba51d5d5..dfa92cee 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -8,7 +8,6 @@ from .__version__ import __version__ from ._models import Headers, Request, Response -from ._types import AsyncByteStream, SyncByteStream KNOWN_HTTP_METHODS = { "CONNECT", @@ -209,12 +208,6 @@ def detach_current( self._detached = True self._span_context_manager.__exit__(exc_type, exc_value, traceback) - def wrap_sync_stream(self, stream: SyncByteStream) -> SyncByteStream: - return OpenTelemetrySyncStream(stream=stream, trace=self) - - def wrap_async_stream(self, stream: AsyncByteStream) -> AsyncByteStream: - return OpenTelemetryAsyncStream(stream=stream, trace=self) - def _set_error(self, error_type: str) -> None: self._metric_attributes["error.type"] = error_type if self._span.is_recording(): @@ -222,45 +215,6 @@ def _set_error(self, error_type: str) -> None: self._span.set_status(self._status(self._status_code.ERROR)) -class OpenTelemetrySyncStream(SyncByteStream): - def __init__(self, *, stream: SyncByteStream, trace: RequestTrace) -> None: - self._stream = stream - self._trace = trace - - def __iter__(self) -> typing.Iterator[bytes]: - try: - yield from self._stream - except BaseException as exc: - self._trace.set_exception(exc) - raise - - def close(self) -> None: - try: - self._stream.close() - finally: - self._trace.close() - - -class OpenTelemetryAsyncStream(AsyncByteStream): - def __init__(self, *, stream: AsyncByteStream, trace: RequestTrace) -> None: - self._stream = stream - self._trace = trace - - async def __aiter__(self) -> typing.AsyncIterator[bytes]: - try: - async for chunk in self._stream: - yield chunk - except BaseException as exc: - self._trace.set_exception(exc) - raise - - async def aclose(self) -> None: - try: - await self._stream.aclose() - finally: - self._trace.close() - - def _request_attributes(request: Request) -> dict[str, typing.Any]: attributes = _request_metric_attributes(request) attributes["url.full"] = _redact_url(request) diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index 3e7ec41d..7803cab8 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -11,18 +11,6 @@ import httpx2._opentelemetry as otel_module -class FailingSyncStream(httpx2.SyncByteStream): - def __iter__(self) -> typing.Iterator[bytes]: - yield b"partial" - raise RuntimeError("stream failed") - - -class FailingAsyncStream(httpx2.AsyncByteStream): - async def __aiter__(self) -> typing.AsyncIterator[bytes]: - yield b"partial" - raise RuntimeError("stream failed") - - @pytest.fixture(autouse=True) def clear_opentelemetry_cache() -> typing.Iterator[None]: otel_module._get_opentelemetry.cache_clear() @@ -290,105 +278,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" -def test_opentelemetry_records_sync_body_read_exceptions(capfire: CaptureLogfire) -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, stream=FailingSyncStream()) - - transport = httpx2.MockTransport(handler) - with httpx2.Client(transport=transport) as client: - with pytest.raises(RuntimeError, match="stream failed"): - client.get("https://example.com/") - - assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( - [ - { - "name": "GET", - "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, - "parent": None, - "start_time": 1000000000, - "end_time": 3000000000, - "instrumentation_scope": "httpx2", - "attributes": { - "http.request.method": "GET", - "server.address": "example.com", - "server.port": 443, - "url.full": "https://example.com/", - "logfire.span_type": "span", - "logfire.msg": "GET", - "http.response.status_code": 200, - "network.protocol.version": "1.1", - "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", - "error.type": "builtins.RuntimeError", - "logfire.level_num": 17, - }, - "events": [ - { - "name": "exception", - "timestamp": 2000000000, - "attributes": { - "exception.type": "RuntimeError", - "exception.message": "stream failed", - "exception.stacktrace": "RuntimeError: stream failed", - "exception.escaped": "False", - }, - } - ], - } - ] - ) - assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" - - -@pytest.mark.anyio -async def test_opentelemetry_records_async_body_read_exceptions(capfire: CaptureLogfire) -> None: - async def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, stream=FailingAsyncStream()) - - transport = httpx2.MockTransport(handler) - async with httpx2.AsyncClient(transport=transport) as client: - with pytest.raises(RuntimeError, match="stream failed"): - await client.get("https://example.com/") - - assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( - [ - { - "name": "GET", - "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, - "parent": None, - "start_time": 1000000000, - "end_time": 3000000000, - "instrumentation_scope": "httpx2", - "attributes": { - "http.request.method": "GET", - "server.address": "example.com", - "server.port": 443, - "url.full": "https://example.com/", - "logfire.span_type": "span", - "logfire.msg": "GET", - "http.response.status_code": 200, - "network.protocol.version": "1.1", - "logfire.exception.fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", - "error.type": "builtins.RuntimeError", - "logfire.level_num": 17, - }, - "events": [ - { - "name": "exception", - "timestamp": 2000000000, - "attributes": { - "exception.type": "RuntimeError", - "exception.message": "stream failed", - "exception.stacktrace": "RuntimeError: stream failed", - "exception.escaped": "False", - }, - } - ], - } - ] - ) - assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "builtins.RuntimeError" - - def test_opentelemetry_honors_configured_known_methods( capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch, From f7c35b556153412fe3156848072e093e6fb7394b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 16:34:31 +0200 Subject: [PATCH 06/11] Move Logfire to test dependencies --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b54c0e55..bce4ee5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "coverage[toml]==7.10.6", "cryptography==46.0.7", "inline-snapshot>=0.34.2", + "logfire>=4.37.0", "pytest>=9.0.3", "pytest-codspeed>=4.1.1", "pytest-httpbin==2.0.0", @@ -36,7 +37,6 @@ dev = [ # Packaging "build==1.3.0", "twine==6.1.0", - "logfire>=4.37.0", ] docs = ["zensical>=0.0.41", "mkdocstrings[python]>=0.27"] bench = [ From 35f207a64a34b9b9920d040582ad79d1d97c526a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 18:29:26 +0200 Subject: [PATCH 07/11] Use context manager for OpenTelemetry requests --- src/httpx2/httpx2/_client.py | 20 ++------------------ src/httpx2/httpx2/_opentelemetry.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index c9dffd18..180d0334 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -1020,17 +1020,9 @@ def _send_single_request(self, request: Request) -> Response: if otel is None or not otel.is_enabled(request): response = transport.handle_request(request) else: - trace = otel.start_request(request) - try: + with otel.trace_request(request) as trace: response = transport.handle_request(request) trace.set_response(response) - except BaseException as exc: - trace.set_exception(exc) - trace.detach_current(type(exc), exc, exc.__traceback__) - trace.close() - raise - trace.detach_current() - trace.close() assert isinstance(response.stream, SyncByteStream) @@ -1780,17 +1772,9 @@ async def _send_single_request(self, request: Request) -> Response: if otel is None or not otel.is_enabled(request): response = await transport.handle_async_request(request) else: - trace = otel.start_request(request) - try: + with otel.trace_request(request) as trace: response = await transport.handle_async_request(request) trace.set_response(response) - except BaseException as exc: - trace.set_exception(exc) - trace.detach_current(type(exc), exc, exc.__traceback__) - trace.close() - raise - trace.detach_current() - trace.close() assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index dfa92cee..6c32fd52 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -4,6 +4,7 @@ import re import time import typing +from contextlib import contextmanager from functools import cache from .__version__ import __version__ @@ -148,6 +149,18 @@ def start_request(self, request: Request) -> RequestTrace: raise return trace + @contextmanager + def trace_request(self, request: Request) -> typing.Iterator[RequestTrace]: + trace = self.start_request(request) + try: + yield trace + except BaseException as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + raise + finally: + trace.close() + class RequestTrace: def __init__( From 4395a25ec9687b911dbb0c4ee6853373c6346228 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 27 Jun 2026 19:33:09 +0200 Subject: [PATCH 08/11] Require OpenTelemetry instrumentation extra --- src/httpx2/httpx2/_opentelemetry.py | 7 +------ src/httpx2/pyproject.toml | 1 + uv.lock | 2 ++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index 6c32fd52..66fa4aaa 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -53,16 +53,11 @@ def get_opentelemetry() -> OpenTelemetry | None: def _get_opentelemetry() -> OpenTelemetry | None: try: from opentelemetry import context, metrics, propagate, trace + from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled from opentelemetry.trace import SpanKind, Status, StatusCode except ImportError: # pragma: no cover return None - is_http_instrumentation_enabled: typing.Callable[[], bool] | None - try: - from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled - except ImportError: # pragma: no cover - is_http_instrumentation_enabled = None - return OpenTelemetry( context=context, metrics=metrics, diff --git a/src/httpx2/pyproject.toml b/src/httpx2/pyproject.toml index 7798401f..180b582d 100644 --- a/src/httpx2/pyproject.toml +++ b/src/httpx2/pyproject.toml @@ -66,6 +66,7 @@ http2 = [ ] opentelemetry = [ "opentelemetry-api>=1.42.0", + "opentelemetry-instrumentation>=0.63b0", ] socks = [ "socksio==1.*", diff --git a/uv.lock b/uv.lock index b6b62643..d1ff7c10 100644 --- a/uv.lock +++ b/uv.lock @@ -1403,6 +1403,7 @@ http2 = [ ] opentelemetry = [ { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, ] socks = [ { name = "socksio" }, @@ -1421,6 +1422,7 @@ requires-dist = [ { name = "httpcore2", editable = "src/httpcore2" }, { name = "idna", specifier = ">=3.18" }, { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.42.0" }, + { name = "opentelemetry-instrumentation", marker = "extra == 'opentelemetry'", specifier = ">=0.63b0" }, { name = "pygments", marker = "extra == 'cli'", specifier = "==2.*" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=10,<16" }, { name = "socksio", marker = "extra == 'socks'", specifier = "==1.*" }, From b617a61a68ba28c0920783a33ce13daffcf8d9b5 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 28 Jun 2026 15:12:02 +0200 Subject: [PATCH 09/11] Do not record cancellations as HTTP errors --- src/httpx2/httpx2/_opentelemetry.py | 4 ++-- tests/httpx2/test_opentelemetry.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index 66fa4aaa..65ab818f 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -137,7 +137,7 @@ def start_request(self, request: Request) -> RequestTrace: ) try: self._propagate.inject(request.headers) - except BaseException as exc: + except Exception as exc: trace.set_exception(exc) trace.detach_current(type(exc), exc, exc.__traceback__) trace.close() @@ -149,7 +149,7 @@ def trace_request(self, request: Request) -> typing.Iterator[RequestTrace]: trace = self.start_request(request) try: yield trace - except BaseException as exc: + except Exception as exc: trace.set_exception(exc) trace.detach_current(type(exc), exc, exc.__traceback__) raise diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index 7803cab8..2c40c775 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import typing import logfire @@ -210,6 +211,38 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metric(capfire)["data"]["data_points"][0]["attributes"]["error.type"] == "httpx2.ConnectError" +def test_opentelemetry_does_not_record_cancelled_error_as_request_error(capfire: CaptureLogfire) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + raise asyncio.CancelledError + + transport = httpx2.MockTransport(handler) + with httpx2.Client(transport=transport) as client: + with pytest.raises(asyncio.CancelledError): + client.get("https://example.com/") + + assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot( + [ + { + "name": "GET", + "context": {"trace_id": 1, "span_id": 1, "is_remote": False}, + "parent": None, + "start_time": 1000000000, + "end_time": 2000000000, + "instrumentation_scope": "httpx2", + "attributes": { + "http.request.method": "GET", + "server.address": "example.com", + "server.port": 443, + "url.full": "https://example.com/", + "logfire.span_type": "span", + "logfire.msg": "GET", + }, + } + ] + ) + assert "error.type" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] + + def test_opentelemetry_records_propagation_injection_exceptions( capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch, From b0020d67e0397a44b310dbddf980bba3bf93d3df Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 28 Jun 2026 15:20:31 +0200 Subject: [PATCH 10/11] Use no-op OpenTelemetry request context --- src/httpx2/httpx2/_client.py | 18 +++++------------- src/httpx2/httpx2/_opentelemetry.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index 180d0334..1978a025 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -28,7 +28,7 @@ request_context, ) from ._models import Cookies, Headers, Request, Response -from ._opentelemetry import get_opentelemetry +from ._opentelemetry import trace_request from ._sse import EventSource from ._status_codes import codes from ._transports.base import AsyncBaseTransport, BaseTransport @@ -1016,13 +1016,9 @@ def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send an async request with a sync Client instance.") with request_context(request=request): - otel = get_opentelemetry() - if otel is None or not otel.is_enabled(request): + with trace_request(request) as trace: response = transport.handle_request(request) - else: - with otel.trace_request(request) as trace: - response = transport.handle_request(request) - trace.set_response(response) + trace.set_response(response) assert isinstance(response.stream, SyncByteStream) @@ -1768,13 +1764,9 @@ async def _send_single_request(self, request: Request) -> Response: raise RuntimeError("Attempted to send a sync request with an AsyncClient instance.") with request_context(request=request): - otel = get_opentelemetry() - if otel is None or not otel.is_enabled(request): + with trace_request(request) as trace: response = await transport.handle_async_request(request) - else: - with otel.trace_request(request) as trace: - response = await transport.handle_async_request(request) - trace.set_response(response) + trace.set_response(response) assert isinstance(response.stream, AsyncByteStream) response.request = request diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index 65ab818f..028f66a4 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -49,6 +49,16 @@ def get_opentelemetry() -> OpenTelemetry | None: return _get_opentelemetry() +@contextmanager +def trace_request(request: Request) -> typing.Iterator[RequestTrace | _NoOpRequestTrace]: + otel = get_opentelemetry() + if otel is None or not otel.is_enabled(request): + yield _NOOP_REQUEST_TRACE + else: + with otel.trace_request(request) as trace: + yield trace + + @cache def _get_opentelemetry() -> OpenTelemetry | None: try: @@ -223,6 +233,14 @@ def _set_error(self, error_type: str) -> None: self._span.set_status(self._status(self._status_code.ERROR)) +class _NoOpRequestTrace: + def set_response(self, response: Response) -> None: + pass + + +_NOOP_REQUEST_TRACE = _NoOpRequestTrace() + + def _request_attributes(request: Request) -> dict[str, typing.Any]: attributes = _request_metric_attributes(request) attributes["url.full"] = _redact_url(request) From 508b0bf80c4a9c5946af277c6c8a8836f5005f69 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 28 Jun 2026 15:35:03 +0200 Subject: [PATCH 11/11] Simplify OpenTelemetry module state --- src/httpx2/httpx2/_opentelemetry.py | 319 ++++++++++++++-------------- tests/httpx2/test_opentelemetry.py | 51 +---- 2 files changed, 172 insertions(+), 198 deletions(-) diff --git a/src/httpx2/httpx2/_opentelemetry.py b/src/httpx2/httpx2/_opentelemetry.py index 028f66a4..175d34c2 100644 --- a/src/httpx2/httpx2/_opentelemetry.py +++ b/src/httpx2/httpx2/_opentelemetry.py @@ -3,13 +3,59 @@ import os import re import time -import typing -from contextlib import contextmanager +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import AbstractContextManager, contextmanager from functools import cache +from types import TracebackType +from typing import TYPE_CHECKING, NamedTuple, Protocol, TypeAlias from .__version__ import __version__ from ._models import Headers, Request, Response +if TYPE_CHECKING: + from opentelemetry.metrics import Histogram, Meter, MeterProvider + from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, TracerProvider + +AttributeValue: TypeAlias = str | bool | int | float | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float] +Attributes: TypeAlias = dict[str, AttributeValue] + + +class PropagateModule(Protocol): + def inject(self, carrier: Headers) -> None: ... + + +class MetricsModule(Protocol): + def get_meter( + self, + name: str, + version: str = "", + meter_provider: MeterProvider | None = None, + schema_url: str | None = None, + attributes: Mapping[str, AttributeValue] | None = None, + ) -> Meter: ... + + +class TraceModule(Protocol): + def get_tracer( + self, + instrumenting_module_name: str, + instrumenting_library_version: str | None = None, + tracer_provider: TracerProvider | None = None, + schema_url: str | None = None, + attributes: Mapping[str, AttributeValue] | None = None, + ) -> Tracer: ... + + +class OpenTelemetryDependencies(NamedTuple): + propagate: PropagateModule + span_kind: type[SpanKind] + status: type[Status] + status_code: type[StatusCode] + is_http_instrumentation_enabled: Callable[[], bool] + tracer: Tracer + duration_histogram: Histogram + + KNOWN_HTTP_METHODS = { "CONNECT", "DELETE", @@ -45,138 +91,95 @@ ) -def get_opentelemetry() -> OpenTelemetry | None: - return _get_opentelemetry() - - @contextmanager -def trace_request(request: Request) -> typing.Iterator[RequestTrace | _NoOpRequestTrace]: - otel = get_opentelemetry() - if otel is None or not otel.is_enabled(request): - yield _NOOP_REQUEST_TRACE +def trace_request(request: Request) -> Iterator[RequestTrace | NoOpRequestTrace]: + dependencies = opentelemetry_dependencies() + if dependencies is None or not is_enabled(request, dependencies): + yield NOOP_REQUEST_TRACE else: - with otel.trace_request(request) as trace: + with record_request(request, dependencies) as trace: yield trace @cache -def _get_opentelemetry() -> OpenTelemetry | None: +def opentelemetry_dependencies() -> OpenTelemetryDependencies | None: try: - from opentelemetry import context, metrics, propagate, trace + from opentelemetry import metrics, propagate, trace from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled from opentelemetry.trace import SpanKind, Status, StatusCode except ImportError: # pragma: no cover return None - return OpenTelemetry( - context=context, - metrics=metrics, + tracer = get_tracer(trace) + meter = get_meter(metrics) + return OpenTelemetryDependencies( propagate=propagate, - trace=trace, span_kind=SpanKind, status=Status, status_code=StatusCode, is_http_instrumentation_enabled=is_http_instrumentation_enabled, + tracer=tracer, + duration_histogram=create_duration_histogram(meter), ) -class OpenTelemetry: - def __init__( - self, - *, - context: typing.Any, - metrics: typing.Any, - propagate: typing.Any, - trace: typing.Any, - span_kind: typing.Any, - status: typing.Any, - status_code: typing.Any, - is_http_instrumentation_enabled: typing.Callable[[], bool] | None, - ) -> None: - self._context = context - self._propagate = propagate - self._trace = trace - self._span_kind = span_kind - self._status = status - self._status_code = status_code - self._is_http_instrumentation_enabled = is_http_instrumentation_enabled - self._suppress_instrumentation_key = getattr(context, "_SUPPRESS_INSTRUMENTATION_KEY", None) - self._suppress_http_instrumentation_key = getattr(context, "_SUPPRESS_HTTP_INSTRUMENTATION_KEY", None) - self._tracer = _get_tracer(trace) - meter = _get_meter(metrics) - self._duration_histogram = _create_duration_histogram(meter) - - def is_enabled(self, request: Request) -> bool: - if not _is_http_url(request): - return False - - if self._is_http_instrumentation_enabled is not None: - return self._is_http_instrumentation_enabled() - - if self._suppress_instrumentation_key is not None and self._context.get_value( - self._suppress_instrumentation_key - ): - return False - - if self._suppress_http_instrumentation_key is not None and self._context.get_value( - self._suppress_http_instrumentation_key - ): - return False - - return True - - def start_request(self, request: Request) -> RequestTrace: - span_attributes = _request_attributes(request) - metric_attributes = _request_metric_attributes(request) - span_name = _span_name(request.method) - span_cm = self._tracer.start_as_current_span( - span_name, - kind=self._span_kind.CLIENT, - attributes=span_attributes, - end_on_exit=False, - ) - span = span_cm.__enter__() - trace = RequestTrace( - span=span, - duration_histogram=self._duration_histogram, - metric_attributes=metric_attributes, - status=self._status, - status_code=self._status_code, - span_context_manager=span_cm, - start=time.perf_counter(), - ) - try: - self._propagate.inject(request.headers) - except Exception as exc: - trace.set_exception(exc) - trace.detach_current(type(exc), exc, exc.__traceback__) - trace.close() - raise - return trace - - @contextmanager - def trace_request(self, request: Request) -> typing.Iterator[RequestTrace]: - trace = self.start_request(request) - try: - yield trace - except Exception as exc: - trace.set_exception(exc) - trace.detach_current(type(exc), exc, exc.__traceback__) - raise - finally: - trace.close() +def is_enabled(request: Request, dependencies: OpenTelemetryDependencies) -> bool: + return is_http_url(request) and dependencies.is_http_instrumentation_enabled() + + +def start_request(request: Request, dependencies: OpenTelemetryDependencies) -> RequestTrace: + span_attributes = request_attributes(request) + metric_attributes = request_metric_attributes(request) + name = span_name(request.method) + span_cm = dependencies.tracer.start_as_current_span( + name, + kind=dependencies.span_kind.CLIENT, + attributes=span_attributes, + end_on_exit=False, + ) + span = span_cm.__enter__() + trace = RequestTrace( + span=span, + duration_histogram=dependencies.duration_histogram, + metric_attributes=metric_attributes, + status=dependencies.status, + status_code=dependencies.status_code, + span_context_manager=span_cm, + start=time.perf_counter(), + ) + try: + dependencies.propagate.inject(request.headers) + except Exception as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + trace.close() + raise + return trace + + +@contextmanager +def record_request(request: Request, dependencies: OpenTelemetryDependencies) -> Iterator[RequestTrace]: + trace = start_request(request, dependencies) + try: + yield trace + except Exception as exc: + trace.set_exception(exc) + trace.detach_current(type(exc), exc, exc.__traceback__) + raise + finally: + trace.close() class RequestTrace: def __init__( self, *, - span: typing.Any, - duration_histogram: typing.Any, - metric_attributes: dict[str, typing.Any], - status: typing.Any, - status_code: typing.Any, - span_context_manager: typing.Any, + span: Span, + duration_histogram: Histogram, + metric_attributes: Attributes, + status: type[Status], + status_code: type[StatusCode], + span_context_manager: AbstractContextManager[Span], start: float, ) -> None: self._span = span @@ -190,17 +193,17 @@ def __init__( self._detached = False def set_response(self, response: Response) -> None: - response_attributes = _response_attributes(response) - self._metric_attributes.update(_response_metric_attributes(response)) - _set_attributes(self._span, response_attributes) + attributes = response_attributes(response) + self._metric_attributes.update(response_metric_attributes(response)) + set_attributes(self._span, attributes) - if _is_error_status(response.status_code): - self._set_error(str(response.status_code)) + if is_error_status(response.status_code): + self.set_error(str(response.status_code)) - def set_exception(self, exc: BaseException) -> None: + def set_exception(self, exc: Exception) -> None: if self._span.is_recording(): self._span.record_exception(exc) - self._set_error(f"{type(exc).__module__}.{type(exc).__qualname__}") + self.set_error(f"{type(exc).__module__}.{type(exc).__qualname__}") def record_duration(self, duration: float) -> None: self._duration_histogram.record(max(duration, 0), attributes=self._metric_attributes) @@ -218,7 +221,7 @@ def detach_current( self, exc_type: type[BaseException] | None = None, exc_value: BaseException | None = None, - traceback: typing.Any = None, + traceback: TracebackType | None = None, ) -> None: if self._detached: return @@ -226,35 +229,35 @@ def detach_current( self._detached = True self._span_context_manager.__exit__(exc_type, exc_value, traceback) - def _set_error(self, error_type: str) -> None: + def set_error(self, error_type: str) -> None: self._metric_attributes["error.type"] = error_type if self._span.is_recording(): self._span.set_attribute("error.type", error_type) self._span.set_status(self._status(self._status_code.ERROR)) -class _NoOpRequestTrace: +class NoOpRequestTrace: def set_response(self, response: Response) -> None: pass -_NOOP_REQUEST_TRACE = _NoOpRequestTrace() +NOOP_REQUEST_TRACE = NoOpRequestTrace() -def _request_attributes(request: Request) -> dict[str, typing.Any]: - attributes = _request_metric_attributes(request) - attributes["url.full"] = _redact_url(request) +def request_attributes(request: Request) -> Attributes: + attributes = request_metric_attributes(request) + attributes["url.full"] = redact_url(request) - captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST") - if captured_headers: - attributes.update(_header_attributes("http.request.header", request.headers, captured_headers)) + header_patterns = captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST") + if header_patterns: + attributes.update(header_attributes("http.request.header", request.headers, header_patterns)) return attributes -def _request_metric_attributes(request: Request) -> dict[str, typing.Any]: - method = _known_method(request.method) - attributes: dict[str, typing.Any] = { +def request_metric_attributes(request: Request) -> Attributes: + method = known_method(request.method) + attributes: Attributes = { "http.request.method": method, } @@ -271,18 +274,18 @@ def _request_metric_attributes(request: Request) -> dict[str, typing.Any]: return attributes -def _response_attributes(response: Response) -> dict[str, typing.Any]: - attributes = _response_metric_attributes(response) +def response_attributes(response: Response) -> Attributes: + attributes = response_metric_attributes(response) - captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE") - if captured_headers: - attributes.update(_header_attributes("http.response.header", response.headers, captured_headers)) + header_patterns = captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE") + if header_patterns: + attributes.update(header_attributes("http.response.header", response.headers, header_patterns)) return attributes -def _response_metric_attributes(response: Response) -> dict[str, typing.Any]: - attributes: dict[str, typing.Any] = {"http.response.status_code": response.status_code} +def response_metric_attributes(response: Response) -> Attributes: + attributes: Attributes = {"http.response.status_code": response.status_code} if response.http_version: attributes["network.protocol.version"] = response.http_version.removeprefix("HTTP/") @@ -290,20 +293,20 @@ def _response_metric_attributes(response: Response) -> dict[str, typing.Any]: return attributes -def _is_http_url(request: Request) -> bool: +def is_http_url(request: Request) -> bool: return request.url.scheme in {"http", "https"} -def _span_name(method: str) -> str: - method = _known_method(method) +def span_name(method: str) -> str: + method = known_method(method) return "HTTP" if method == "_OTHER" else method -def _known_method(method: str) -> str: - return method if method in _known_methods() else "_OTHER" +def known_method(method: str) -> str: + return method if method in known_methods() else "_OTHER" -def _known_methods() -> set[str]: +def known_methods() -> set[str]: configured_methods = os.environ.get("OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS") if configured_methods is None: return KNOWN_HTTP_METHODS @@ -311,17 +314,17 @@ def _known_methods() -> set[str]: return {method.strip().upper() for method in configured_methods.split(",") if method.strip()} -def _redact_url(request: Request) -> str: +def redact_url(request: Request) -> str: if request.url.userinfo: return str(request.url.copy_with(username="REDACTED", password="REDACTED")) return str(request.url) -def _is_error_status(status_code: int) -> bool: +def is_error_status(status_code: int) -> bool: return status_code >= 400 -def _set_attributes(span: typing.Any, attributes: dict[str, typing.Any]) -> None: +def set_attributes(span: Span, attributes: Attributes) -> None: if not span.is_recording(): return @@ -329,40 +332,40 @@ def _set_attributes(span: typing.Any, attributes: dict[str, typing.Any]) -> None span.set_attribute(name, value) -def _captured_headers(name: str) -> list[re.Pattern[str]]: +def captured_headers(name: str) -> list[re.Pattern[str]]: value = os.environ.get(name, "") return [re.compile(pattern.strip(), re.IGNORECASE) for pattern in value.split(",") if pattern.strip()] -def _header_attributes( +def header_attributes( prefix: str, headers: Headers, captured_headers: list[re.Pattern[str]], -) -> dict[str, list[str]]: - sensitive_headers = _sensitive_headers() - attributes: dict[str, list[str]] = {} +) -> Attributes: + sensitive_headers = sensitive_header_patterns() + attributes: Attributes = {} for key in headers.keys(): if not any(pattern.fullmatch(key) for pattern in captured_headers): continue attribute = f"{prefix}.{key.lower().replace('-', '_')}" values = headers.get_list(key, split_commas=True) - attributes[attribute] = [ - "[REDACTED]" if _is_sensitive_header(key, sensitive_headers) else value for value in values - ] + attributes[attribute] = tuple( + "[REDACTED]" if is_sensitive_header(key, sensitive_headers) else value for value in values + ) return attributes -def _sensitive_headers() -> list[re.Pattern[str]]: +def sensitive_header_patterns() -> list[re.Pattern[str]]: value = os.environ.get("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", "") names = [*SENSITIVE_HEADERS, *[item.strip() for item in value.split(",") if item.strip()]] return [re.compile(name, re.IGNORECASE) for name in names] -def _is_sensitive_header(key: str, sensitive_headers: list[re.Pattern[str]]) -> bool: +def is_sensitive_header(key: str, sensitive_headers: list[re.Pattern[str]]) -> bool: return any(pattern.fullmatch(key) for pattern in sensitive_headers) -def _get_tracer(trace: typing.Any) -> typing.Any: +def get_tracer(trace: TraceModule) -> Tracer: try: return trace.get_tracer( INSTRUMENTATION_NAME, @@ -373,18 +376,18 @@ def _get_tracer(trace: typing.Any) -> typing.Any: return trace.get_tracer(INSTRUMENTATION_NAME, __version__) -def _get_meter(metrics: typing.Any) -> typing.Any: +def get_meter(metrics: MetricsModule) -> Meter: try: return metrics.get_meter( INSTRUMENTATION_NAME, - instrumenting_library_version=__version__, + version=__version__, schema_url=SEMCONV_SCHEMA_URL, ) except TypeError: # pragma: no cover return metrics.get_meter(INSTRUMENTATION_NAME, __version__) -def _create_duration_histogram(meter: typing.Any) -> typing.Any: +def create_duration_histogram(meter: Meter) -> Histogram: try: return meter.create_histogram( name=CLIENT_REQUEST_DURATION, diff --git a/tests/httpx2/test_opentelemetry.py b/tests/httpx2/test_opentelemetry.py index 2c40c775..35a169e4 100644 --- a/tests/httpx2/test_opentelemetry.py +++ b/tests/httpx2/test_opentelemetry.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -import typing +from collections.abc import Iterator +from typing import Any, cast import logfire import pytest @@ -13,10 +14,10 @@ @pytest.fixture(autouse=True) -def clear_opentelemetry_cache() -> typing.Iterator[None]: - otel_module._get_opentelemetry.cache_clear() +def clear_opentelemetry_cache() -> Iterator[None]: + otel_module.opentelemetry_dependencies.cache_clear() yield - otel_module._get_opentelemetry.cache_clear() + otel_module.opentelemetry_dependencies.cache_clear() def test_sync_client_emits_current_http_client_span_and_duration_metric(capfire: CaptureLogfire) -> None: @@ -124,36 +125,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert _duration_metrics(capfire) == [] -def test_opentelemetry_honors_context_suppression_fallback(capfire: CaptureLogfire) -> None: - otel = otel_module.get_opentelemetry() - assert otel is not None - otel._is_http_instrumentation_enabled = None - - request = httpx2.Request("GET", "https://example.com/") - assert otel.is_enabled(request) is True - - with logfire.suppress_instrumentation(): - assert otel.is_enabled(request) is False - - assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) - - -def test_opentelemetry_honors_http_context_suppression_fallback(capfire: CaptureLogfire) -> None: - otel = otel_module.get_opentelemetry() - assert otel is not None - otel._is_http_instrumentation_enabled = None - otel._suppress_http_instrumentation_key = "httpx2.suppress_http_instrumentation" - - request = httpx2.Request("GET", "https://example.com/") - token = otel._context.attach(otel._context.set_value(otel._suppress_http_instrumentation_key, True)) - try: - assert otel.is_enabled(request) is False - finally: - otel._context.detach(token) - - assert capfire.exporter.exported_spans_as_dict(include_instrumentation_scope=True) == snapshot([]) - - def test_opentelemetry_records_exceptions(capfire: CaptureLogfire) -> None: def handler(request: httpx2.Request) -> httpx2.Response: raise httpx2.ConnectError("no route") @@ -247,8 +218,8 @@ def test_opentelemetry_records_propagation_injection_exceptions( capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch, ) -> None: - otel = otel_module.get_opentelemetry() - assert otel is not None + dependencies = otel_module.opentelemetry_dependencies() + assert dependencies is not None def inject(headers: httpx2.Headers) -> None: raise RuntimeError("inject failed") @@ -256,7 +227,7 @@ def inject(headers: httpx2.Headers) -> None: def handler(request: httpx2.Request) -> httpx2.Response: pytest.fail("transport should not be called") # pragma: no cover - monkeypatch.setattr(otel._propagate, "inject", inject) + monkeypatch.setattr(dependencies.propagate, "inject", inject) transport = httpx2.MockTransport(handler) with httpx2.Client(transport=transport) as client: @@ -431,14 +402,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "http.request.header.x_private" not in _duration_metric(capfire)["data"]["data_points"][0]["attributes"] -def _duration_metric(capfire: CaptureLogfire) -> dict[str, typing.Any]: +def _duration_metric(capfire: CaptureLogfire) -> dict[str, Any]: [metric] = _duration_metrics(capfire) return metric -def _duration_metrics(capfire: CaptureLogfire) -> list[dict[str, typing.Any]]: +def _duration_metrics(capfire: CaptureLogfire) -> list[dict[str, Any]]: try: - metrics = typing.cast(typing.Any, capfire).get_collected_metrics() + metrics = cast(Any, capfire).get_collected_metrics() except AttributeError: return [] return [metric for metric in metrics if metric["name"] == "http.client.request.duration"]