Skip to content

Commit 692ec02

Browse files
authored
httpx2 support in logfire.instrument_httpx() (#2095)
1 parent 3fe874c commit 692ec02

8 files changed

Lines changed: 832 additions & 158 deletions

File tree

logfire/_internal/cli/run.py

Lines changed: 193 additions & 44 deletions
Large diffs are not rendered by default.

logfire/_internal/integrations/httpx.py

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@
77
from email.headerregistry import ContentTypeHeader
88
from email.policy import EmailPolicy
99
from functools import cached_property, lru_cache
10+
from importlib import import_module
11+
from types import ModuleType
1012
from typing import TYPE_CHECKING, Any, Literal, cast
1113

12-
import httpx
1314
from opentelemetry.trace import NonRecordingSpan, Span, use_span
1415

1516
from logfire._internal.config import GLOBAL_CONFIG
1617
from logfire._internal.stack_info import warn_at_user_stacklevel
1718

1819
try:
19-
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
20+
from opentelemetry.instrumentation import httpx as otel_httpx
2021

2122
from logfire.integrations.httpx import (
2223
AsyncRequestHook,
@@ -37,15 +38,83 @@
3738
from logfire._internal.main import set_user_attributes_on_raw_span
3839
from logfire._internal.utils import handle_internal_errors
3940

41+
HTTPXClientInstrumentor = otel_httpx.HTTPXClientInstrumentor
42+
4043
if TYPE_CHECKING:
4144
from typing import ParamSpec
4245

46+
import httpx
47+
import httpx2
48+
49+
HTTPX2ClientInstrumentor: type[otel_httpx.HTTPX2ClientInstrumentor] | None = otel_httpx.HTTPX2ClientInstrumentor
50+
4351
P = ParamSpec('P')
52+
else:
53+
HTTPX2ClientInstrumentor: Any = getattr(otel_httpx, 'HTTPX2ClientInstrumentor', None)
54+
55+
56+
def _import_optional_client_module(name: str) -> ModuleType | None:
57+
try:
58+
return import_module(name)
59+
except ModuleNotFoundError as error:
60+
if error.name != name:
61+
raise
62+
return None
63+
64+
65+
_httpx = _import_optional_client_module('httpx')
66+
_httpx2 = _import_optional_client_module('httpx2')
67+
68+
69+
def _client_module_types(name: str) -> tuple[type[Any], ...]:
70+
return tuple(cast(type[Any], getattr(module, name)) for module in (_httpx, _httpx2) if module is not None)
71+
72+
73+
_HTTPX_BYTE_STREAM_TYPES = _client_module_types('ByteStream')
74+
_HTTPX_RESPONSE_TYPES = _client_module_types('Response')
75+
_HTTPX_RESPONSE_NOT_READ_TYPES = cast(tuple[type[BaseException], ...], _client_module_types('ResponseNotRead'))
76+
77+
78+
def _httpx2_instrumentation_error() -> RuntimeError:
79+
return RuntimeError(
80+
'Instrumenting `httpx2` requires `opentelemetry-instrumentation-httpx>=0.65b0`.\n'
81+
'You can update this with:\n'
82+
" pip install -U 'logfire[httpx]' 'opentelemetry-instrumentation-httpx>=0.65b0'"
83+
)
84+
85+
86+
def _instrumentors_for_installed_modules() -> list[Any]:
87+
instrumentors: list[Any] = []
88+
if _httpx is not None:
89+
instrumentors.append(HTTPXClientInstrumentor())
90+
if _httpx2 is not None:
91+
if HTTPX2ClientInstrumentor is None:
92+
if not instrumentors:
93+
raise _httpx2_instrumentation_error()
94+
warn_at_user_stacklevel(
95+
'`httpx2` will not be instrumented because it requires `opentelemetry-instrumentation-httpx>=0.65b0`.',
96+
UserWarning,
97+
)
98+
else:
99+
instrumentors.append(HTTPX2ClientInstrumentor())
100+
if not instrumentors:
101+
raise RuntimeError('`logfire.instrument_httpx()` requires either the `httpx` or `httpx2` package.')
102+
return instrumentors
103+
104+
105+
def _instrumentor_for_client(client: Any) -> tuple[Any, bool]:
106+
if _httpx is not None and isinstance(client, (_httpx.Client, _httpx.AsyncClient)):
107+
return HTTPXClientInstrumentor(), isinstance(client, _httpx.AsyncClient)
108+
if _httpx2 is not None and isinstance(client, (_httpx2.Client, _httpx2.AsyncClient)):
109+
if HTTPX2ClientInstrumentor is None:
110+
raise _httpx2_instrumentation_error()
111+
return HTTPX2ClientInstrumentor(), isinstance(client, _httpx2.AsyncClient)
112+
raise TypeError(f'Expected an `httpx` or `httpx2` client, got {type(client).__name__}.')
44113

45114

46115
def instrument_httpx(
47116
logfire_instance: Logfire,
48-
client: httpx.Client | httpx.AsyncClient | None,
117+
client: httpx.Client | httpx.AsyncClient | httpx2.Client | httpx2.AsyncClient | None,
49118
capture_all: bool | None,
50119
capture_headers: bool,
51120
capture_request_body: bool,
@@ -56,7 +125,7 @@ def instrument_httpx(
56125
async_response_hook: AsyncResponseHook | None,
57126
**kwargs: Any,
58127
) -> None:
59-
"""Instrument the `httpx` module so that spans are automatically created for each request.
128+
"""Instrument the `httpx` and `httpx2` modules so that spans are automatically created for each request.
60129
61130
See the `Logfire.instrument_httpx` method for details.
62131
"""
@@ -100,7 +169,6 @@ def instrument_httpx(
100169
}
101170
del kwargs # make sure only final_kwargs is used
102171

103-
instrumentor = HTTPXClientInstrumentor()
104172
logfire_instance = logfire_instance.with_settings(custom_scope_suffix='httpx')
105173

106174
if client is None:
@@ -125,9 +193,11 @@ def instrument_httpx(
125193
logfire_instance,
126194
)
127195

128-
instrumentor.instrument(**final_kwargs)
196+
for instrumentor in _instrumentors_for_installed_modules():
197+
instrumentor.instrument(**final_kwargs)
129198
else:
130-
if isinstance(client, httpx.AsyncClient):
199+
instrumentor, is_async = _instrumentor_for_client(client)
200+
if is_async:
131201
request_hook = make_async_request_hook(
132202
request_hook,
133203
should_capture_request_headers,
@@ -166,7 +236,7 @@ def instrument_httpx(
166236

167237

168238
class LogfireHttpxInfoMixin:
169-
headers: httpx.Headers
239+
headers: httpx.Headers | httpx2.Headers
170240

171241
@property
172242
def content_type_header_object(self) -> ContentTypeHeader:
@@ -214,7 +284,7 @@ def capture_text_as_json(self, attr_name: str, text: str):
214284

215285
@property
216286
def body_is_streaming(self):
217-
return not isinstance(self.stream, httpx.ByteStream)
287+
return not isinstance(self.stream, _HTTPX_BYTE_STREAM_TYPES)
218288

219289
@property
220290
def content_type_charset(self):
@@ -263,13 +333,13 @@ def hook(span: LogfireSpan):
263333
self.on_response_read(hook)
264334

265335
@cached_property
266-
def response(self) -> httpx.Response:
336+
def response(self) -> httpx.Response | httpx2.Response:
267337
frame = inspect.currentframe().f_back.f_back # pyright: ignore[reportOptionalMemberAccess]
268338
while frame: # pragma: no branch
269339
response = frame.f_locals.get('response')
270340
frame = frame.f_back
271-
if isinstance(response, httpx.Response):
272-
return response
341+
if isinstance(response, _HTTPX_RESPONSE_TYPES):
342+
return cast('httpx.Response | httpx2.Response', response)
273343
raise RuntimeError('Could not find the response object') # pragma: no cover
274344

275345
def on_response_read(self, hook: Callable[[LogfireSpan], None]):
@@ -301,7 +371,7 @@ def read() -> bytes:
301371
try:
302372
# Only log the body the first time it's read
303373
return response.content
304-
except httpx.ResponseNotRead:
374+
except _HTTPX_RESPONSE_NOT_READ_TYPES:
305375
return hook(original_read)
306376

307377
response.read = read
@@ -315,7 +385,7 @@ async def aread() -> bytes:
315385
try:
316386
# Only log the body the first time it's read
317387
return response.content
318-
except httpx.ResponseNotRead:
388+
except _HTTPX_RESPONSE_NOT_READ_TYPES:
319389
return await hook(original_aread)
320390

321391
response.aread = aread
@@ -477,7 +547,7 @@ def run_hook(hook: Callable[P, Any] | None, *args: P.args, **kwargs: P.kwargs) -
477547

478548

479549
def capture_request_or_response_headers(
480-
span: Span, headers: httpx.Headers, request_or_response: Literal['request', 'response']
550+
span: Span, headers: httpx.Headers | httpx2.Headers, request_or_response: Literal['request', 'response']
481551
) -> None:
482552
span.set_attributes(
483553
{
@@ -489,11 +559,13 @@ def capture_request_or_response_headers(
489559

490560
CODES_FOR_METHODS_WITH_DATA_PARAM = [
491561
inspect.unwrap(method).__code__
562+
for module in (_httpx, _httpx2)
563+
if module is not None
492564
for method in [
493-
httpx.Client.request,
494-
httpx.Client.stream,
495-
httpx.AsyncClient.request,
496-
httpx.AsyncClient.stream,
565+
module.Client.request,
566+
module.Client.stream,
567+
module.AsyncClient.request,
568+
module.AsyncClient.stream,
497569
]
498570
]
499571

logfire/_internal/main.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575

7676
import anthropic
7777
import httpx
78+
import httpx2
7879
import openai
7980
import pydantic_ai.models
8081
import requests
@@ -1537,7 +1538,7 @@ def instrument_asyncpg(self, capture_parameters: bool = False, **kwargs: Any) ->
15371538
@overload
15381539
def instrument_httpx(
15391540
self,
1540-
client: httpx.Client,
1541+
client: httpx.Client | httpx2.Client,
15411542
*,
15421543
capture_all: bool = False,
15431544
capture_headers: bool = False,
@@ -1551,7 +1552,7 @@ def instrument_httpx(
15511552
@overload
15521553
def instrument_httpx(
15531554
self,
1554-
client: httpx.AsyncClient,
1555+
client: httpx.AsyncClient | httpx2.AsyncClient,
15551556
*,
15561557
capture_all: bool = False,
15571558
capture_headers: bool = False,
@@ -1580,7 +1581,7 @@ def instrument_httpx(
15801581

15811582
def instrument_httpx(
15821583
self,
1583-
client: httpx.Client | httpx.AsyncClient | None = None,
1584+
client: httpx.Client | httpx.AsyncClient | httpx2.Client | httpx2.AsyncClient | None = None,
15841585
*,
15851586
capture_all: bool | None = None,
15861587
capture_headers: bool = False,
@@ -1592,17 +1593,18 @@ def instrument_httpx(
15921593
async_response_hook: HttpxAsyncResponseHook | None = None,
15931594
**kwargs: Any,
15941595
) -> None:
1595-
"""Instrument the `httpx` module so that spans are automatically created for each request.
1596+
"""Instrument the `httpx` and `httpx2` modules so that spans are automatically created for each request.
15961597
1597-
Optionally, pass an `httpx.Client` instance to instrument only that client.
1598+
Optionally, pass an `httpx` or `httpx2` client instance to instrument only that client.
15981599
15991600
Uses the
16001601
[OpenTelemetry HTTPX Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/httpx/httpx.html)
1601-
library, specifically `HTTPXClientInstrumentor().instrument()`, to which it passes `**kwargs`.
1602+
library, specifically `HTTPXClientInstrumentor().instrument()` (or `HTTPX2ClientInstrumentor` for `httpx2`),
1603+
to which it passes `**kwargs`.
16021604
16031605
Args:
1604-
client: The `httpx.Client` or `httpx.AsyncClient` instance to instrument.
1605-
If `None`, the default, all clients will be instrumented.
1606+
client: The `httpx` or `httpx2` client instance to instrument.
1607+
If `None`, the default, all clients from both installed libraries will be instrumented.
16061608
capture_all: Set to `True` to capture all HTTP headers, request and response bodies.
16071609
By default checks the environment variable `LOGFIRE_HTTPX_CAPTURE_ALL`.
16081610
capture_headers: Set to `True` to capture all HTTP headers.

logfire/integrations/httpx.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
from __future__ import annotations
22

33
from collections.abc import Awaitable, Callable
4-
from typing import Any, NamedTuple
4+
from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias
55

6-
import httpx
76
from opentelemetry.trace import Span
87

9-
# TODO(Marcelo): When https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3098/ gets merged,
10-
# and the next version of `opentelemetry-instrumentation-httpx` is released, we can just do a reimport:
11-
# from opentelemetry.instrumentation.httpx import RequestInfo as RequestInfo
12-
# from opentelemetry.instrumentation.httpx import ResponseInfo as ResponseInfo
13-
# from opentelemetry.instrumentation.httpx import RequestHook as RequestHook
14-
# from opentelemetry.instrumentation.httpx import ResponseHook as ResponseHook
8+
if TYPE_CHECKING:
9+
import httpx
10+
import httpx2
11+
12+
_HTTPXURL: TypeAlias = httpx.URL | httpx2.URL
13+
_HTTPXHeaders: TypeAlias = httpx.Headers | httpx2.Headers
14+
_HTTPXStream: TypeAlias = (
15+
httpx.SyncByteStream | httpx.AsyncByteStream | httpx2.SyncByteStream | httpx2.AsyncByteStream
16+
)
17+
else:
18+
_HTTPXURL = _HTTPXHeaders = _HTTPXStream = Any
1519

1620

1721
class RequestInfo(NamedTuple):
@@ -21,9 +25,9 @@ class RequestInfo(NamedTuple):
2125
"""
2226

2327
method: bytes
24-
url: httpx.URL
25-
headers: httpx.Headers
26-
stream: httpx.SyncByteStream | httpx.AsyncByteStream | None
28+
url: _HTTPXURL
29+
headers: _HTTPXHeaders
30+
stream: _HTTPXStream | None
2731
extensions: dict[str, Any] | None
2832

2933

@@ -34,8 +38,8 @@ class ResponseInfo(NamedTuple):
3438
"""
3539

3640
status_code: int
37-
headers: httpx.Headers
38-
stream: httpx.SyncByteStream | httpx.AsyncByteStream | None
41+
headers: _HTTPXHeaders
42+
stream: _HTTPXStream | None
3943
extensions: dict[str, Any] | None
4044

4145

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ pytest_logfire = "logfire._internal.integrations.pytest"
106106
[dependency-groups]
107107
dev = [
108108
"httpx >= 0.27.2",
109+
"httpx2 >= 2.0.0",
109110
"aiohttp >= 3.10.9",
110111
"redis >= 5.1.1",
111112
"pymongo >= 4.10.1",

0 commit comments

Comments
 (0)