Skip to content

Commit 54ff986

Browse files
authored
Add capture_request_form_data param to instrument_httpx (#711)
1 parent 5ef452b commit 54ff986

File tree

3 files changed

+131
-19
lines changed

3 files changed

+131
-19
lines changed

logfire/_internal/integrations/httpx.py

+69-17
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import inspect
44
from contextlib import suppress
55
from email.message import Message
6-
from typing import TYPE_CHECKING, Any, Callable, Literal, cast, overload
6+
from typing import TYPE_CHECKING, Any, Callable, Literal, Mapping, cast, overload
77

88
import httpx
9+
import opentelemetry.sdk.trace
910

1011
from logfire.propagate import attach_context, get_context
1112

@@ -67,6 +68,7 @@ def instrument_httpx(
6768
capture_response_headers: bool,
6869
capture_request_json_body: bool,
6970
capture_response_json_body: bool,
71+
capture_request_form_data: bool,
7072
**kwargs: Unpack[ClientKwargs],
7173
) -> None: ...
7274

@@ -78,6 +80,7 @@ def instrument_httpx(
7880
capture_response_headers: bool,
7981
capture_request_json_body: bool,
8082
capture_response_json_body: bool,
83+
capture_request_form_data: bool,
8184
**kwargs: Unpack[AsyncClientKwargs],
8285
) -> None: ...
8386

@@ -89,6 +92,7 @@ def instrument_httpx(
8992
capture_response_headers: bool,
9093
capture_request_json_body: bool,
9194
capture_response_json_body: bool,
95+
capture_request_form_data: bool,
9296
**kwargs: Unpack[HTTPXInstrumentKwargs],
9397
) -> None: ...
9498

@@ -100,6 +104,7 @@ def instrument_httpx(
100104
capture_response_headers: bool,
101105
capture_request_json_body: bool,
102106
capture_response_json_body: bool,
107+
capture_request_form_data: bool,
103108
**kwargs: Any,
104109
) -> None:
105110
"""Instrument the `httpx` module so that spans are automatically created for each request.
@@ -122,13 +127,13 @@ def instrument_httpx(
122127
async_request_hook = cast('AsyncRequestHook | None', final_kwargs.get('async_request_hook'))
123128
async_response_hook = cast('AsyncResponseHook | None', final_kwargs.get('async_response_hook'))
124129
final_kwargs['request_hook'] = make_request_hook(
125-
request_hook, capture_request_headers, capture_request_json_body
130+
request_hook, capture_request_headers, capture_request_json_body, capture_request_form_data
126131
)
127132
final_kwargs['response_hook'] = make_response_hook(
128133
response_hook, capture_response_headers, capture_response_json_body, logfire_instance
129134
)
130135
final_kwargs['async_request_hook'] = make_async_request_hook(
131-
async_request_hook, capture_request_headers, capture_request_json_body
136+
async_request_hook, capture_request_headers, capture_request_json_body, capture_request_form_data
132137
)
133138
final_kwargs['async_response_hook'] = make_async_response_hook(
134139
async_response_hook, capture_response_headers, capture_response_json_body, logfire_instance
@@ -140,15 +145,19 @@ def instrument_httpx(
140145
request_hook = cast('RequestHook | AsyncRequestHook | None', final_kwargs.get('request_hook'))
141146
response_hook = cast('ResponseHook | AsyncResponseHook | None', final_kwargs.get('response_hook'))
142147

143-
request_hook = make_async_request_hook(request_hook, capture_request_headers, capture_request_json_body)
148+
request_hook = make_async_request_hook(
149+
request_hook, capture_request_headers, capture_request_json_body, capture_request_form_data
150+
)
144151
response_hook = make_async_response_hook(
145152
response_hook, capture_response_headers, capture_response_json_body, logfire_instance
146153
)
147154
else:
148155
request_hook = cast('RequestHook | None', final_kwargs.get('request_hook'))
149156
response_hook = cast('ResponseHook | None', final_kwargs.get('response_hook'))
150157

151-
request_hook = make_request_hook(request_hook, capture_request_headers, capture_request_json_body)
158+
request_hook = make_request_hook(
159+
request_hook, capture_request_headers, capture_request_json_body, capture_request_form_data
160+
)
152161
response_hook = make_response_hook(
153162
response_hook, capture_response_headers, capture_response_json_body, logfire_instance
154163
)
@@ -158,39 +167,51 @@ def instrument_httpx(
158167

159168

160169
def make_request_hook(
161-
hook: RequestHook | None, should_capture_headers: bool, should_capture_json: bool
170+
hook: RequestHook | None, should_capture_headers: bool, should_capture_json: bool, should_capture_form_data: bool
162171
) -> RequestHook | None:
163-
if not should_capture_headers and not should_capture_json and not hook:
172+
if not should_capture_headers and not should_capture_json and not should_capture_form_data and not hook:
164173
return None
165174

166175
def new_hook(span: Span, request: RequestInfo) -> None:
167176
with handle_internal_errors():
168-
if should_capture_headers:
169-
capture_request_headers(span, request)
170-
if should_capture_json:
171-
capture_request_body(span, request)
177+
capture_request(request, span, should_capture_headers, should_capture_json, should_capture_form_data)
172178
run_hook(hook, span, request)
173179

174180
return new_hook
175181

176182

177183
def make_async_request_hook(
178-
hook: AsyncRequestHook | RequestHook | None, should_capture_headers: bool, should_capture_json: bool
184+
hook: AsyncRequestHook | RequestHook | None,
185+
should_capture_headers: bool,
186+
should_capture_json: bool,
187+
should_capture_form_data: bool,
179188
) -> AsyncRequestHook | None:
180-
if not should_capture_headers and not should_capture_json and not hook:
189+
if not should_capture_headers and not should_capture_json and not should_capture_form_data and not hook:
181190
return None
182191

183192
async def new_hook(span: Span, request: RequestInfo) -> None:
184193
with handle_internal_errors():
185-
if should_capture_headers:
186-
capture_request_headers(span, request)
187-
if should_capture_json:
188-
capture_request_body(span, request)
194+
capture_request(request, span, should_capture_headers, should_capture_json, should_capture_form_data)
189195
await run_async_hook(hook, span, request)
190196

191197
return new_hook
192198

193199

200+
def capture_request(
201+
request: RequestInfo,
202+
span: Span,
203+
should_capture_headers: bool,
204+
should_capture_json: bool,
205+
should_capture_form_data: bool,
206+
) -> None:
207+
if should_capture_headers:
208+
capture_request_headers(span, request)
209+
if should_capture_json:
210+
capture_request_body(span, request)
211+
if should_capture_form_data:
212+
capture_request_form_data(span, request)
213+
214+
194215
def make_response_hook(
195216
hook: ResponseHook | None, should_capture_headers: bool, should_capture_json: bool, logfire_instance: Logfire
196217
) -> ResponseHook | None:
@@ -338,3 +359,34 @@ def capture_request_body(span: Span, request: RequestInfo) -> None:
338359
attr_name = 'http.request.body.json'
339360
set_user_attributes_on_raw_span(span, {attr_name: {}}) # type: ignore
340361
span.set_attribute(attr_name, body)
362+
363+
364+
CODES_FOR_METHODS_WITH_DATA_PARAM = [
365+
inspect.unwrap(method).__code__
366+
for method in [
367+
httpx.Client.request,
368+
httpx.Client.stream,
369+
httpx.AsyncClient.request,
370+
httpx.AsyncClient.stream,
371+
]
372+
]
373+
374+
375+
def capture_request_form_data(span: Span, request: RequestInfo) -> None:
376+
content_type = cast('httpx.Headers', request.headers).get('content-type', '')
377+
if content_type != 'application/x-www-form-urlencoded':
378+
return
379+
380+
frame = inspect.currentframe().f_back.f_back.f_back # type: ignore
381+
while frame:
382+
if frame.f_code in CODES_FOR_METHODS_WITH_DATA_PARAM:
383+
break
384+
frame = frame.f_back
385+
else: # pragma: no cover
386+
return
387+
388+
data = frame.f_locals.get('data')
389+
if not (data and isinstance(data, Mapping)): # pragma: no cover
390+
return
391+
span = cast(opentelemetry.sdk.trace.Span, span)
392+
set_user_attributes_on_raw_span(span, {'http.request.body.form': data})

logfire/_internal/main.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -1153,42 +1153,50 @@ def instrument_asyncpg(self, **kwargs: Unpack[AsyncPGInstrumentKwargs]) -> None:
11531153
def instrument_httpx(
11541154
self,
11551155
client: httpx.Client,
1156+
*,
11561157
capture_request_headers: bool = False,
11571158
capture_response_headers: bool = False,
11581159
capture_request_json_body: bool = False,
11591160
capture_response_json_body: bool = False,
1161+
capture_request_form_data: bool = False,
11601162
**kwargs: Unpack[ClientKwargs],
11611163
) -> None: ...
11621164

11631165
@overload
11641166
def instrument_httpx(
11651167
self,
11661168
client: httpx.AsyncClient,
1169+
*,
11671170
capture_request_headers: bool = False,
11681171
capture_response_headers: bool = False,
11691172
capture_request_json_body: bool = False,
11701173
capture_response_json_body: bool = False,
1174+
capture_request_form_data: bool = False,
11711175
**kwargs: Unpack[AsyncClientKwargs],
11721176
) -> None: ...
11731177

11741178
@overload
11751179
def instrument_httpx(
11761180
self,
11771181
client: None = None,
1182+
*,
11781183
capture_request_headers: bool = False,
11791184
capture_response_headers: bool = False,
11801185
capture_request_json_body: bool = False,
11811186
capture_response_json_body: bool = False,
1187+
capture_request_form_data: bool = False,
11821188
**kwargs: Unpack[HTTPXInstrumentKwargs],
11831189
) -> None: ...
11841190

11851191
def instrument_httpx(
11861192
self,
11871193
client: httpx.Client | httpx.AsyncClient | None = None,
1194+
*,
11881195
capture_request_headers: bool = False,
11891196
capture_response_headers: bool = False,
11901197
capture_request_json_body: bool = False,
11911198
capture_response_json_body: bool = False,
1199+
capture_request_form_data: bool = False,
11921200
**kwargs: Any,
11931201
) -> None:
11941202
"""Instrument the `httpx` module so that spans are automatically created for each request.
@@ -1205,7 +1213,17 @@ def instrument_httpx(
12051213
capture_request_headers: Set to `True` to capture all request headers.
12061214
capture_response_headers: Set to `True` to capture all response headers.
12071215
capture_request_json_body: Set to `True` to capture the request JSON body.
1216+
Specifically captures the raw request body whenever the content type is `application/json`.
1217+
Doesn't check if the body is actually JSON.
12081218
capture_response_json_body: Set to `True` to capture the response JSON body.
1219+
Specifically captures the raw response body whenever the content type is `application/json`
1220+
when the `response.read()` or `.aread()` method is first called,
1221+
which happens automatically for non-streaming requests.
1222+
For streaming requests, the body is not captured if it's merely iterated over.
1223+
Doesn't check if the body is actually JSON.
1224+
capture_request_form_data: Set to `True` to capture the request form data.
1225+
Specifically captures the `data` argument of `httpx` methods like `post` and `put`.
1226+
Doesn't inspect or parse the raw request body.
12091227
**kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` method, for future compatibility.
12101228
"""
12111229
from .integrations.httpx import instrument_httpx
@@ -1214,10 +1232,11 @@ def instrument_httpx(
12141232
return instrument_httpx(
12151233
self,
12161234
client,
1217-
capture_request_headers,
1218-
capture_response_headers,
1235+
capture_request_headers=capture_request_headers,
1236+
capture_response_headers=capture_response_headers,
12191237
capture_request_json_body=capture_request_json_body,
12201238
capture_response_json_body=capture_response_json_body,
1239+
capture_request_form_data=capture_request_form_data,
12211240
**kwargs,
12221241
)
12231242

tests/otel_integrations/test_httpx.py

+41
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import logfire
1717
import logfire._internal.integrations.httpx
18+
from logfire._internal.integrations.httpx import CODES_FOR_METHODS_WITH_DATA_PARAM
1819
from logfire.testing import TestExporter
1920

2021
pytestmark = pytest.mark.anyio
@@ -452,6 +453,7 @@ async def test_async_httpx_client_capture_full(exporter: TestExporter):
452453
capture_request_json_body=True,
453454
capture_response_headers=True,
454455
capture_response_json_body=True,
456+
capture_request_form_data=True,
455457
)
456458
response = await client.post('https://example.org/', json={'hello': 'world'})
457459
checker(response)
@@ -558,3 +560,42 @@ async def test_httpx_async_client_capture_json_response_checks_header(exporter:
558560
assert len(spans) == 1
559561
assert spans[0]['name'] == 'POST'
560562
assert 'http.response.body.json' not in str(spans)
563+
564+
565+
def test_httpx_client_capture_request_form_data(exporter: TestExporter):
566+
assert len({code.co_filename for code in CODES_FOR_METHODS_WITH_DATA_PARAM}) == 1
567+
assert [code.co_name for code in CODES_FOR_METHODS_WITH_DATA_PARAM] == ['request', 'stream', 'request', 'stream']
568+
569+
with httpx.Client(transport=create_transport()) as client:
570+
logfire.instrument_httpx(client, capture_request_form_data=True)
571+
client.post('https://example.org/', data={'form': 'values'})
572+
573+
assert exporter.exported_spans_as_dict() == snapshot(
574+
[
575+
{
576+
'name': 'POST',
577+
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
578+
'parent': None,
579+
'start_time': 1000000000,
580+
'end_time': 2000000000,
581+
'attributes': {
582+
'http.method': 'POST',
583+
'http.request.method': 'POST',
584+
'http.url': 'https://example.org/',
585+
'url.full': 'https://example.org/',
586+
'http.host': 'example.org',
587+
'server.address': 'example.org',
588+
'network.peer.address': 'example.org',
589+
'logfire.span_type': 'span',
590+
'logfire.msg': 'POST /',
591+
'http.request.body.form': '{"form":"values"}',
592+
'logfire.json_schema': '{"type":"object","properties":{"http.request.body.form":{"type":"object"}}}',
593+
'http.status_code': 200,
594+
'http.response.status_code': 200,
595+
'http.flavor': '1.1',
596+
'network.protocol.version': '1.1',
597+
'http.target': '/',
598+
},
599+
}
600+
]
601+
)

0 commit comments

Comments
 (0)