3
3
import inspect
4
4
from contextlib import suppress
5
5
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
7
7
8
8
import httpx
9
+ import opentelemetry .sdk .trace
9
10
10
11
from logfire .propagate import attach_context , get_context
11
12
@@ -67,6 +68,7 @@ def instrument_httpx(
67
68
capture_response_headers : bool ,
68
69
capture_request_json_body : bool ,
69
70
capture_response_json_body : bool ,
71
+ capture_request_form_data : bool ,
70
72
** kwargs : Unpack [ClientKwargs ],
71
73
) -> None : ...
72
74
@@ -78,6 +80,7 @@ def instrument_httpx(
78
80
capture_response_headers : bool ,
79
81
capture_request_json_body : bool ,
80
82
capture_response_json_body : bool ,
83
+ capture_request_form_data : bool ,
81
84
** kwargs : Unpack [AsyncClientKwargs ],
82
85
) -> None : ...
83
86
@@ -89,6 +92,7 @@ def instrument_httpx(
89
92
capture_response_headers : bool ,
90
93
capture_request_json_body : bool ,
91
94
capture_response_json_body : bool ,
95
+ capture_request_form_data : bool ,
92
96
** kwargs : Unpack [HTTPXInstrumentKwargs ],
93
97
) -> None : ...
94
98
@@ -100,6 +104,7 @@ def instrument_httpx(
100
104
capture_response_headers : bool ,
101
105
capture_request_json_body : bool ,
102
106
capture_response_json_body : bool ,
107
+ capture_request_form_data : bool ,
103
108
** kwargs : Any ,
104
109
) -> None :
105
110
"""Instrument the `httpx` module so that spans are automatically created for each request.
@@ -122,13 +127,13 @@ def instrument_httpx(
122
127
async_request_hook = cast ('AsyncRequestHook | None' , final_kwargs .get ('async_request_hook' ))
123
128
async_response_hook = cast ('AsyncResponseHook | None' , final_kwargs .get ('async_response_hook' ))
124
129
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
126
131
)
127
132
final_kwargs ['response_hook' ] = make_response_hook (
128
133
response_hook , capture_response_headers , capture_response_json_body , logfire_instance
129
134
)
130
135
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
132
137
)
133
138
final_kwargs ['async_response_hook' ] = make_async_response_hook (
134
139
async_response_hook , capture_response_headers , capture_response_json_body , logfire_instance
@@ -140,15 +145,19 @@ def instrument_httpx(
140
145
request_hook = cast ('RequestHook | AsyncRequestHook | None' , final_kwargs .get ('request_hook' ))
141
146
response_hook = cast ('ResponseHook | AsyncResponseHook | None' , final_kwargs .get ('response_hook' ))
142
147
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
+ )
144
151
response_hook = make_async_response_hook (
145
152
response_hook , capture_response_headers , capture_response_json_body , logfire_instance
146
153
)
147
154
else :
148
155
request_hook = cast ('RequestHook | None' , final_kwargs .get ('request_hook' ))
149
156
response_hook = cast ('ResponseHook | None' , final_kwargs .get ('response_hook' ))
150
157
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
+ )
152
161
response_hook = make_response_hook (
153
162
response_hook , capture_response_headers , capture_response_json_body , logfire_instance
154
163
)
@@ -158,39 +167,51 @@ def instrument_httpx(
158
167
159
168
160
169
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
162
171
) -> 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 :
164
173
return None
165
174
166
175
def new_hook (span : Span , request : RequestInfo ) -> None :
167
176
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 )
172
178
run_hook (hook , span , request )
173
179
174
180
return new_hook
175
181
176
182
177
183
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 ,
179
188
) -> 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 :
181
190
return None
182
191
183
192
async def new_hook (span : Span , request : RequestInfo ) -> None :
184
193
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 )
189
195
await run_async_hook (hook , span , request )
190
196
191
197
return new_hook
192
198
193
199
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
+
194
215
def make_response_hook (
195
216
hook : ResponseHook | None , should_capture_headers : bool , should_capture_json : bool , logfire_instance : Logfire
196
217
) -> ResponseHook | None :
@@ -338,3 +359,34 @@ def capture_request_body(span: Span, request: RequestInfo) -> None:
338
359
attr_name = 'http.request.body.json'
339
360
set_user_attributes_on_raw_span (span , {attr_name : {}}) # type: ignore
340
361
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 })
0 commit comments