Skip to content

Commit 2ef9725

Browse files
Kludexalexmojaki
andauthored
httpx: support capture_request_json_body (#682)
Co-authored-by: Alex Hall <alex.mojaki@gmail.com>
1 parent 4316680 commit 2ef9725

File tree

4 files changed

+257
-91
lines changed

4 files changed

+257
-91
lines changed

logfire-api/logfire_api/_internal/integrations/httpx.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ def make_capture_request_headers_hook(hook: RequestHook | None) -> RequestHook:
3838
def make_capture_async_request_headers_hook(hook: AsyncRequestHook | None) -> AsyncRequestHook: ...
3939
async def run_async_hook(hook: Callable[P, Any] | None, *args: P.args, **kwargs: P.kwargs) -> None: ...
4040
def run_hook(hook: Callable[P, Any] | None, *args: P.args, **kwargs: P.kwargs) -> None: ...
41-
def capture_response_headers(span: Span, response: ResponseInfo) -> None: ...
41+
def capture_response_headers(span: Span, request: RequestInfo, response: ResponseInfo) -> None: ...
4242
def capture_request_headers(span: Span, request: RequestInfo) -> None: ...
4343
def capture_headers(span: Span, headers: httpx.Headers, request_or_response: Literal['request', 'response']) -> None: ...

logfire/_internal/integrations/httpx.py

Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3-
import functools
43
import inspect
4+
from contextlib import suppress
5+
from email.message import Message
56
from typing import TYPE_CHECKING, Any, Callable, Literal, cast, overload
67

78
import httpx
@@ -24,6 +25,8 @@
2425
)
2526

2627
from 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

2831
if 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

182207
async 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)

logfire/_internal/main.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,7 @@ def instrument_httpx(
11641164
client: httpx.Client,
11651165
capture_request_headers: bool = False,
11661166
capture_response_headers: bool = False,
1167+
capture_request_json_body: bool = False,
11671168
**kwargs: Unpack[ClientKwargs],
11681169
) -> None: ...
11691170

@@ -1173,6 +1174,7 @@ def instrument_httpx(
11731174
client: httpx.AsyncClient,
11741175
capture_request_headers: bool = False,
11751176
capture_response_headers: bool = False,
1177+
capture_request_json_body: bool = False,
11761178
**kwargs: Unpack[AsyncClientKwargs],
11771179
) -> None: ...
11781180

@@ -1182,6 +1184,7 @@ def instrument_httpx(
11821184
client: None = None,
11831185
capture_request_headers: bool = False,
11841186
capture_response_headers: bool = False,
1187+
capture_request_json_body: bool = False,
11851188
**kwargs: Unpack[HTTPXInstrumentKwargs],
11861189
) -> None: ...
11871190

@@ -1190,6 +1193,7 @@ def instrument_httpx(
11901193
client: httpx.Client | httpx.AsyncClient | None = None,
11911194
capture_request_headers: bool = False,
11921195
capture_response_headers: bool = False,
1196+
capture_request_json_body: bool = False,
11931197
**kwargs: Any,
11941198
) -> None:
11951199
"""Instrument the `httpx` module so that spans are automatically created for each request.
@@ -1199,11 +1203,26 @@ def instrument_httpx(
11991203
Uses the
12001204
[OpenTelemetry HTTPX Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/httpx/httpx.html)
12011205
library, specifically `HTTPXClientInstrumentor().instrument()`, to which it passes `**kwargs`.
1206+
1207+
Args:
1208+
client: The `httpx.Client` or `httpx.AsyncClient` instance to instrument.
1209+
If `None`, the default, all clients will be instrumented.
1210+
capture_request_headers: Set to `True` to capture all request headers.
1211+
capture_response_headers: Set to `True` to capture all response headers.
1212+
capture_request_json_body: Set to `True` to capture the request JSON body.
1213+
**kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` method, for future compatibility.
12021214
"""
12031215
from .integrations.httpx import instrument_httpx
12041216

12051217
self._warn_if_not_initialized_for_instrumentation()
1206-
return instrument_httpx(self, client, capture_request_headers, capture_response_headers, **kwargs)
1218+
return instrument_httpx(
1219+
self,
1220+
client,
1221+
capture_request_headers,
1222+
capture_response_headers,
1223+
capture_request_json_body=capture_request_json_body,
1224+
**kwargs,
1225+
)
12071226

12081227
def instrument_celery(self, **kwargs: Unpack[CeleryInstrumentKwargs]) -> None:
12091228
"""Instrument `celery` so that spans are automatically created for each task.

0 commit comments

Comments
 (0)