Skip to content

Commit 32a9721

Browse files
authored
feat(tornado): Apply data_collection filtering to URL query strings (#6834)
Rebuild url.full from the parsed base URL plus the filtered query string when data_collection is enabled, so filtered/redacted query params are reflected in url.full instead of the raw request query. Previously the unfiltered query string leaked into url.full even when data_collection filtering was active. Update the span-streaming tests to expect the filtered query in url.full (and a bare path when filtering strips all params). Refs PY-2583 Refs #6743
1 parent 260677e commit 32a9721

2 files changed

Lines changed: 284 additions & 2 deletions

File tree

‎sentry_sdk/integrations/tornado.py‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sentry_sdk
66
from sentry_sdk.api import continue_trace
77
from sentry_sdk.consts import OP, SPANDATA
8+
from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string
89
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
910
from sentry_sdk.integrations._wsgi_common import (
1011
RequestExtractor,
@@ -24,6 +25,8 @@
2425
capture_internal_exceptions,
2526
ensure_integration_enabled,
2627
event_from_exception,
28+
has_data_collection_enabled,
29+
parse_url,
2730
transaction_from_function,
2831
)
2932

@@ -189,6 +192,7 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None]
189192

190193
def _get_request_attributes(request: "Any") -> "Dict[str, Any]":
191194
attributes = {} # type: Dict[str, Any]
195+
client_options = sentry_sdk.get_client().options
192196

193197
if request.method:
194198
attributes[SPANDATA.HTTP_REQUEST_METHOD] = request.method.upper()
@@ -197,7 +201,24 @@ def _get_request_attributes(request: "Any") -> "Dict[str, Any]":
197201
for header, value in headers.items():
198202
attributes[f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}"] = value
199203

200-
if should_send_default_pii():
204+
if has_data_collection_enabled(client_options):
205+
attributes["url.path"] = request.path
206+
207+
filtered_query = None
208+
if request.query:
209+
filtered_query = _apply_data_collection_filtering_to_query_string(
210+
query_string=request.query,
211+
behaviour=client_options["data_collection"]["url_query_params"],
212+
)
213+
if filtered_query:
214+
attributes[SPANDATA.URL_QUERY] = filtered_query
215+
216+
parsed_url = parse_url(request.full_url())
217+
attributes[SPANDATA.URL_FULL] = (
218+
f"{parsed_url.url}?{filtered_query}" if filtered_query else parsed_url.url
219+
)
220+
221+
elif should_send_default_pii():
201222
attributes[SPANDATA.URL_FULL] = request.full_url()
202223
attributes["url.path"] = request.path
203224

@@ -273,7 +294,18 @@ def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event":
273294
request.path,
274295
)
275296

276-
request_info["query_string"] = request.query
297+
client_options = sentry_sdk.get_client().options
298+
if has_data_collection_enabled(client_options):
299+
if request.query:
300+
filtered_query = _apply_data_collection_filtering_to_query_string(
301+
query_string=request.query,
302+
behaviour=client_options["data_collection"]["url_query_params"],
303+
)
304+
if filtered_query:
305+
request_info["query_string"] = filtered_query
306+
else:
307+
request_info["query_string"] = request.query
308+
277309
request_info["method"] = request.method
278310
request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
279311
request_info["headers"] = _filter_headers(dict(request.headers))

‎tests/integrations/tornado/test_tornado.py‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,256 @@ def test_cookie_data_collection(
238238
assert event["request"]["cookies"] == expected_cookies
239239

240240

241+
class QueryHandler(RequestHandler):
242+
async def get(self):
243+
self.write("ok")
244+
245+
246+
_QUERY_PARAM_DATA_COLLECTION_CASES = [
247+
pytest.param(
248+
{"send_default_pii": True},
249+
"toy=tennisball&color=red&auth=secret",
250+
id="send_default_pii_true",
251+
),
252+
pytest.param(
253+
{"send_default_pii": False},
254+
None,
255+
id="send_default_pii_false",
256+
),
257+
pytest.param(
258+
{},
259+
None,
260+
id="defaults",
261+
),
262+
pytest.param(
263+
{"_experiments": {"data_collection": {}}},
264+
"toy=tennisball&color=red&auth=%5BFiltered%5D",
265+
id="data_collection_denylist_default",
266+
),
267+
pytest.param(
268+
{
269+
"_experiments": {
270+
"data_collection": {
271+
"url_query_params": {"mode": "denylist", "terms": ["toy"]}
272+
}
273+
}
274+
},
275+
"toy=%5BFiltered%5D&color=red&auth=%5BFiltered%5D",
276+
id="data_collection_denylist_custom_terms",
277+
),
278+
pytest.param(
279+
{
280+
"_experiments": {
281+
"data_collection": {
282+
"url_query_params": {"mode": "allowlist", "terms": ["toy"]}
283+
}
284+
}
285+
},
286+
"toy=tennisball&color=%5BFiltered%5D&auth=%5BFiltered%5D",
287+
id="data_collection_allowlist",
288+
),
289+
pytest.param(
290+
{
291+
"_experiments": {
292+
"data_collection": {
293+
"url_query_params": {"mode": "allowlist", "terms": ["auth"]}
294+
}
295+
}
296+
},
297+
"toy=%5BFiltered%5D&color=%5BFiltered%5D&auth=%5BFiltered%5D",
298+
id="data_collection_allowlist_sensitive_term",
299+
),
300+
pytest.param(
301+
{"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}},
302+
None,
303+
id="data_collection_off",
304+
),
305+
pytest.param(
306+
{
307+
"send_default_pii": True,
308+
"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}},
309+
},
310+
None,
311+
id="data_collection_wins_over_send_default_pii",
312+
),
313+
]
314+
315+
316+
@pytest.mark.parametrize(
317+
"init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES
318+
)
319+
def test_url_query_data_collection_span_streaming(
320+
tornado_testcase, sentry_init, capture_items, init_kwargs, expected_query
321+
):
322+
init_kwargs = dict(init_kwargs)
323+
sentry_init(
324+
integrations=[TornadoIntegration()],
325+
traces_sample_rate=1.0,
326+
trace_lifecycle="stream",
327+
**init_kwargs,
328+
)
329+
330+
items = capture_items("span")
331+
332+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
333+
response = client.fetch("/hi?toy=tennisball&color=red&auth=secret")
334+
assert response.code == 200
335+
336+
sentry_sdk.flush()
337+
338+
(server_span,) = [item.payload for item in items]
339+
340+
data_collection_enabled = "data_collection" in init_kwargs.get("_experiments", {})
341+
url_attrs_expected = data_collection_enabled or init_kwargs.get(
342+
"send_default_pii", False
343+
)
344+
345+
if expected_query is None:
346+
assert "url.query" not in server_span["attributes"]
347+
if url_attrs_expected:
348+
assert server_span["attributes"]["url.full"].endswith("/hi")
349+
assert server_span["attributes"]["url.path"] == "/hi"
350+
else:
351+
assert "url.full" not in server_span["attributes"]
352+
assert "url.path" not in server_span["attributes"]
353+
else:
354+
assert server_span["attributes"]["url.query"] == expected_query
355+
assert server_span["attributes"]["url.full"].endswith(f"/hi?{expected_query}")
356+
assert server_span["attributes"]["url.full"].startswith("http://")
357+
assert server_span["attributes"]["url.path"] == "/hi"
358+
359+
360+
@pytest.mark.parametrize(
361+
"init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES
362+
)
363+
def test_url_query_data_collection_event_processor(
364+
tornado_testcase, sentry_init, capture_events, init_kwargs, expected_query
365+
):
366+
sentry_init(
367+
integrations=[TornadoIntegration()],
368+
traces_sample_rate=1.0,
369+
trace_lifecycle="static",
370+
**init_kwargs,
371+
)
372+
373+
events = capture_events()
374+
375+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
376+
response = client.fetch("/hi?toy=tennisball&color=red&auth=secret")
377+
assert response.code == 200
378+
379+
sentry_sdk.flush()
380+
381+
(event,) = events
382+
383+
assert event["request"]["url"].endswith("/hi")
384+
assert event["request"]["method"] == "GET"
385+
if "data_collection" not in init_kwargs.get("_experiments", {}):
386+
assert (
387+
event["request"]["query_string"] == "toy=tennisball&color=red&auth=secret"
388+
)
389+
elif expected_query is None:
390+
assert "query_string" not in event["request"]
391+
else:
392+
assert event["request"]["query_string"] == expected_query
393+
394+
395+
def test_url_query_data_collection_no_query_string(
396+
tornado_testcase, sentry_init, capture_items
397+
):
398+
sentry_init(
399+
integrations=[TornadoIntegration()],
400+
traces_sample_rate=1.0,
401+
trace_lifecycle="stream",
402+
_experiments={"data_collection": {}},
403+
)
404+
405+
items = capture_items("span")
406+
407+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
408+
response = client.fetch("/hi")
409+
assert response.code == 200
410+
411+
sentry_sdk.flush()
412+
413+
(server_span,) = [item.payload for item in items]
414+
415+
assert "url.query" not in server_span["attributes"]
416+
assert server_span["attributes"]["url.full"].endswith("/hi")
417+
assert server_span["attributes"]["url.path"] == "/hi"
418+
419+
420+
def test_url_query_data_collection_repeated_and_blank_params(
421+
tornado_testcase, sentry_init, capture_items
422+
):
423+
sentry_init(
424+
integrations=[TornadoIntegration()],
425+
traces_sample_rate=1.0,
426+
trace_lifecycle="stream",
427+
_experiments={"data_collection": {}},
428+
)
429+
430+
items = capture_items("span")
431+
432+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
433+
response = client.fetch("/hi?a=1&a=2&b=")
434+
assert response.code == 200
435+
436+
sentry_sdk.flush()
437+
438+
(server_span,) = [item.payload for item in items]
439+
440+
assert server_span["attributes"]["url.query"] == "a=1&a=2&b="
441+
442+
443+
def test_url_query_data_collection__event_processor_no_query_string(
444+
tornado_testcase, sentry_init, capture_events
445+
):
446+
sentry_init(
447+
integrations=[TornadoIntegration()],
448+
traces_sample_rate=1.0,
449+
trace_lifecycle="static",
450+
_experiments={"data_collection": {}},
451+
)
452+
453+
events = capture_events()
454+
455+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
456+
response = client.fetch("/hi")
457+
assert response.code == 200
458+
459+
sentry_sdk.flush()
460+
461+
(event,) = events
462+
463+
assert "query_string" not in event["request"]
464+
assert event["request"]["url"].endswith("/hi")
465+
assert event["request"]["method"] == "GET"
466+
467+
468+
def test_url_query_data_collection_event_processor_repeated_and_blank_params(
469+
tornado_testcase, sentry_init, capture_events
470+
):
471+
sentry_init(
472+
integrations=[TornadoIntegration()],
473+
traces_sample_rate=1.0,
474+
trace_lifecycle="static",
475+
_experiments={"data_collection": {}},
476+
)
477+
478+
events = capture_events()
479+
480+
client = tornado_testcase(Application([(r"/hi", QueryHandler)]))
481+
response = client.fetch("/hi?a=1&a=2&b=")
482+
assert response.code == 200
483+
484+
sentry_sdk.flush()
485+
486+
(event,) = events
487+
488+
assert event["request"]["query_string"] == "a=1&a=2&b="
489+
490+
241491
@pytest.mark.parametrize("send_pii", [True, False])
242492
@pytest.mark.parametrize("span_streaming", [True, False])
243493
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)