11from __future__ import annotations
22
3- import functools
43import inspect
4+ from contextlib import suppress
5+ from email .message import Message
56from typing import TYPE_CHECKING , Any , Callable , Literal , cast , overload
67
78import httpx
2425 )
2526
2627from logfire import Logfire
28+ from logfire ._internal .main import set_user_attributes_on_raw_span
29+ from logfire ._internal .utils import handle_internal_errors
2730
2831if TYPE_CHECKING :
2932 from typing import ParamSpec , TypedDict , TypeVar , Unpack
@@ -60,6 +63,7 @@ def instrument_httpx(
6063 client : httpx .Client ,
6164 capture_request_headers : bool ,
6265 capture_response_headers : bool ,
66+ capture_request_json_body : bool ,
6367 ** kwargs : Unpack [ClientKwargs ],
6468 ) -> None : ...
6569
@@ -69,6 +73,7 @@ def instrument_httpx(
6973 client : httpx .AsyncClient ,
7074 capture_request_headers : bool ,
7175 capture_response_headers : bool ,
76+ capture_request_json_body : bool ,
7277 ** kwargs : Unpack [AsyncClientKwargs ],
7378 ) -> None : ...
7479
@@ -78,6 +83,7 @@ def instrument_httpx(
7883 client : None ,
7984 capture_request_headers : bool ,
8085 capture_response_headers : bool ,
86+ capture_request_json_body : bool ,
8187 ** kwargs : Unpack [HTTPXInstrumentKwargs ],
8288 ) -> None : ...
8389
@@ -87,6 +93,7 @@ def instrument_httpx(
8793 client : httpx .Client | httpx .AsyncClient | None ,
8894 capture_request_headers : bool ,
8995 capture_response_headers : bool ,
96+ capture_request_json_body : bool ,
9097 ** kwargs : Any ,
9198) -> None :
9299 """Instrument the `httpx` module so that spans are automatically created for each request.
@@ -107,76 +114,94 @@ def instrument_httpx(
107114 response_hook = cast ('ResponseHook | None' , final_kwargs .get ('response_hook' ))
108115 async_request_hook = cast ('AsyncRequestHook | None' , final_kwargs .get ('async_request_hook' ))
109116 async_response_hook = cast ('AsyncResponseHook | None' , final_kwargs .get ('async_response_hook' ))
110-
111- if capture_request_headers : # pragma: no cover
112- final_kwargs [ 'request_hook' ] = make_capture_request_headers_hook ( request_hook )
113- final_kwargs ['async_request_hook ' ] = make_capture_async_request_headers_hook ( async_request_hook )
114-
115- if capture_response_headers : # pragma: no cover
116- final_kwargs [ 'response_hook' ] = make_capture_response_headers_hook ( response_hook )
117- final_kwargs ['async_response_hook' ] = make_capture_async_response_headers_hook (async_response_hook )
117+ final_kwargs [ 'request_hook' ] = make_request_hook (
118+ request_hook , capture_request_headers , capture_request_json_body
119+ )
120+ final_kwargs ['response_hook ' ] = make_response_hook ( response_hook , capture_response_headers )
121+ final_kwargs [ 'async_request_hook' ] = make_async_request_hook (
122+ async_request_hook , capture_request_headers , capture_request_json_body
123+ )
124+ final_kwargs ['async_response_hook' ] = make_async_response_hook (async_response_hook , capture_response_headers )
118125
119126 instrumentor .instrument (** final_kwargs )
120127 else :
121- request_hook = cast ('RequestHook | AsyncRequestHook | None' , final_kwargs .get ('request_hook' ))
122- response_hook = cast ('ResponseHook | AsyncResponseHook | None' , final_kwargs .get ('response_hook' ))
123-
124- if capture_request_headers :
125- if isinstance (client , httpx .AsyncClient ):
126- request_hook = cast ('AsyncRequestHook | None' , request_hook )
127- request_hook = make_capture_async_request_headers_hook (request_hook )
128- else :
129- request_hook = cast ('RequestHook | None' , request_hook )
130- request_hook = make_capture_request_headers_hook (request_hook )
131- else :
132- if isinstance (client , httpx .AsyncClient ):
133- request_hook = functools .partial (run_async_hook , request_hook )
134-
135- if capture_response_headers :
136- if isinstance (client , httpx .AsyncClient ):
137- response_hook = cast ('AsyncResponseHook | None' , response_hook )
138- response_hook = make_capture_async_response_headers_hook (response_hook )
139- else :
140- response_hook = cast ('ResponseHook | None' , response_hook )
141- response_hook = make_capture_response_headers_hook (response_hook )
128+ if isinstance (client , httpx .AsyncClient ):
129+ request_hook = cast ('RequestHook | AsyncRequestHook | None' , final_kwargs .get ('request_hook' ))
130+ response_hook = cast ('ResponseHook | AsyncResponseHook | None' , final_kwargs .get ('response_hook' ))
131+
132+ request_hook = make_async_request_hook (request_hook , capture_request_headers , capture_request_json_body )
133+ response_hook = make_async_response_hook (response_hook , capture_response_headers )
142134 else :
143- if isinstance (client , httpx .AsyncClient ):
144- response_hook = functools .partial (run_async_hook , response_hook )
135+ request_hook = cast ('RequestHook | None' , final_kwargs .get ('request_hook' ))
136+ response_hook = cast ('ResponseHook | None' , final_kwargs .get ('response_hook' ))
137+
138+ request_hook = make_request_hook (request_hook , capture_request_headers , capture_request_json_body )
139+ response_hook = make_response_hook (response_hook , capture_response_headers )
145140
146141 tracer_provider = final_kwargs ['tracer_provider' ]
147142 instrumentor .instrument_client (client , tracer_provider , request_hook , response_hook )
148143
149144
150- def make_capture_response_headers_hook (hook : ResponseHook | None ) -> ResponseHook :
151- def capture_response_headers_hook (span : Span , request : RequestInfo , response : ResponseInfo ) -> None :
152- capture_response_headers (span , response )
153- run_hook (hook , span , request , response )
145+ def make_request_hook (
146+ hook : RequestHook | None , should_capture_headers : bool , should_capture_json : bool
147+ ) -> RequestHook | None :
148+ if not should_capture_headers and not should_capture_json and not hook :
149+ return None
150+
151+ def new_hook (span : Span , request : RequestInfo ) -> None :
152+ with handle_internal_errors ():
153+ if should_capture_headers :
154+ capture_request_headers (span , request )
155+ if should_capture_json :
156+ capture_request_body (span , request )
157+ run_hook (hook , span , request )
158+
159+ return new_hook
160+
161+
162+ def make_async_request_hook (
163+ hook : AsyncRequestHook | RequestHook | None , should_capture_headers : bool , should_capture_json : bool
164+ ) -> AsyncRequestHook | None :
165+ if not should_capture_headers and not should_capture_json and not hook :
166+ return None
154167
155- return capture_response_headers_hook
168+ async def new_hook (span : Span , request : RequestInfo ) -> None :
169+ with handle_internal_errors ():
170+ if should_capture_headers :
171+ capture_request_headers (span , request )
172+ if should_capture_json :
173+ capture_request_body (span , request )
174+ await run_async_hook (hook , span , request )
156175
176+ return new_hook
157177
158- def make_capture_async_response_headers_hook (hook : AsyncResponseHook | None ) -> AsyncResponseHook :
159- async def capture_response_headers_hook (span : Span , request : RequestInfo , response : ResponseInfo ) -> None :
160- capture_response_headers (span , response )
161- await run_async_hook (hook , span , request , response )
162178
163- return capture_response_headers_hook
179+ def make_response_hook (hook : ResponseHook | None , should_capture_headers : bool ) -> ResponseHook | None :
180+ if not should_capture_headers and not hook :
181+ return None
164182
183+ def new_hook (span : Span , request : RequestInfo , response : ResponseInfo ) -> None :
184+ with handle_internal_errors ():
185+ if should_capture_headers :
186+ capture_response_headers (span , response )
187+ run_hook (hook , span , request , response )
165188
166- def make_capture_request_headers_hook (hook : RequestHook | None ) -> RequestHook :
167- def capture_request_headers_hook (span : Span , request : RequestInfo ) -> None :
168- capture_request_headers (span , request )
169- run_hook (hook , span , request )
189+ return new_hook
170190
171- return capture_request_headers_hook
172191
192+ def make_async_response_hook (
193+ hook : ResponseHook | AsyncResponseHook | None , should_capture_headers : bool
194+ ) -> AsyncResponseHook | None :
195+ if not should_capture_headers and not hook :
196+ return None
173197
174- def make_capture_async_request_headers_hook (hook : AsyncRequestHook | None ) -> AsyncRequestHook :
175- async def capture_request_headers_hook (span : Span , request : RequestInfo ) -> None :
176- capture_request_headers (span , request )
177- await run_async_hook (hook , span , request )
198+ async def new_hook (span : Span , request : RequestInfo , response : ResponseInfo ) -> None :
199+ with handle_internal_errors ():
200+ if should_capture_headers :
201+ capture_response_headers (span , response )
202+ await run_async_hook (hook , span , request , response )
178203
179- return capture_request_headers_hook
204+ return new_hook
180205
181206
182207async def run_async_hook (hook : Callable [P , Any ] | None , * args : P .args , ** kwargs : P .kwargs ) -> None :
@@ -206,3 +231,33 @@ def capture_headers(span: Span, headers: httpx.Headers, request_or_response: Lit
206231 for header_name in headers .keys ()
207232 }
208233 )
234+
235+
236+ def get_charset (content_type : str ) -> str :
237+ m = Message ()
238+ m ['content-type' ] = content_type
239+ return cast (str , m .get_param ('charset' , 'utf-8' ))
240+
241+
242+ def decode_body (body : bytes , content_type : str ):
243+ charset = get_charset (content_type )
244+ with suppress (UnicodeDecodeError , LookupError ):
245+ return body .decode (charset )
246+ if charset .lower () not in ('utf-8' , 'utf8' ):
247+ with suppress (UnicodeDecodeError ):
248+ return body .decode ('utf-8' )
249+ return body .decode (charset , errors = 'replace' )
250+
251+
252+ def capture_request_body (span : Span , request : RequestInfo ) -> None :
253+ content_type = cast ('httpx.Headers' , request .headers ).get ('content-type' , '' ).lower ()
254+ if not isinstance (request .stream , httpx .ByteStream ):
255+ return
256+ if not content_type .startswith ('application/json' ):
257+ return
258+
259+ body = decode_body (list (request .stream )[0 ], content_type )
260+
261+ attr_name = 'http.request.body.json'
262+ set_user_attributes_on_raw_span (span , {attr_name : {}}) # type: ignore
263+ span .set_attribute (attr_name , body )
0 commit comments