77from email .headerregistry import ContentTypeHeader
88from email .policy import EmailPolicy
99from functools import cached_property , lru_cache
10+ from importlib import import_module
11+ from types import ModuleType
1012from typing import TYPE_CHECKING , Any , Literal , cast
1113
12- import httpx
1314from opentelemetry .trace import NonRecordingSpan , Span , use_span
1415
1516from logfire ._internal .config import GLOBAL_CONFIG
1617from logfire ._internal .stack_info import warn_at_user_stacklevel
1718
1819try :
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 ,
3738from logfire ._internal .main import set_user_attributes_on_raw_span
3839from logfire ._internal .utils import handle_internal_errors
3940
41+ HTTPXClientInstrumentor = otel_httpx .HTTPXClientInstrumentor
42+
4043if 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
46115def 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
168238class 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
479549def 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
490560CODES_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
0 commit comments