Skip to content

Commit ea22fc5

Browse files
committed
feat(quart): Apply data_collection filtering to URL query strings
Ref PY-2583 Ref #6743
1 parent c3baf08 commit ea22fc5

2 files changed

Lines changed: 188 additions & 1 deletion

File tree

sentry_sdk/integrations/quart.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import TYPE_CHECKING
66

77
import sentry_sdk
8+
from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string
89
from sentry_sdk.integrations import DidNotEnable, Integration
910
from sentry_sdk.integrations._wsgi_common import _filter_headers
1011
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
@@ -17,6 +18,8 @@
1718
capture_internal_exceptions,
1819
ensure_integration_enabled,
1920
event_from_exception,
21+
has_data_collection_enabled,
22+
parse_url,
2023
)
2124

2225
if TYPE_CHECKING:
@@ -203,7 +206,39 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None:
203206

204207
segment.set_attributes(header_attributes)
205208

206-
if should_send_default_pii():
209+
client_options = sentry_sdk.get_client().options
210+
filtered_query_string = None
211+
if has_data_collection_enabled(client_options):
212+
query_string = request_websocket.query_string.decode(
213+
"utf-8", errors="replace"
214+
)
215+
if query_string:
216+
filtered_query_string = (
217+
_apply_data_collection_filtering_to_query_string(
218+
query_string=query_string,
219+
behaviour=client_options["data_collection"][
220+
"url_query_params"
221+
],
222+
)
223+
)
224+
if filtered_query_string:
225+
segment.set_attribute(
226+
"url.query",
227+
filtered_query_string,
228+
)
229+
230+
parsed_url = parse_url(request_websocket.url)
231+
segment.set_attribute(
232+
"url.full",
233+
f"{parsed_url.url}?{filtered_query_string}"
234+
if filtered_query_string
235+
else parsed_url.url,
236+
)
237+
238+
# TODO: Add the user properties that are seen in the branch below here once
239+
# code is added to respect the `user_info` settings within the data collection
240+
# configuration
241+
elif should_send_default_pii():
207242
segment.set_attribute("url.full", request_websocket.url)
208243
segment.set_attribute(
209244
"url.query",

tests/integrations/quart/test_quart.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,3 +1131,155 @@ async def test_span_streaming_sensitive_header_passthrough_with_pii_and_no_data_
11311131
segment["attributes"]["http.request.header.authorization"]
11321132
== "Bearer secret-token"
11331133
)
1134+
1135+
1136+
NO_QUERY_STRING = object()
1137+
_QUERY_PARAM_DATA_COLLECTION_CASES = [
1138+
pytest.param(
1139+
{"send_default_pii": True},
1140+
"toy=tennisball&color=red&auth=secret",
1141+
id="send_default_pii_true",
1142+
),
1143+
pytest.param(
1144+
{"send_default_pii": False},
1145+
NO_QUERY_STRING,
1146+
id="send_default_pii_false",
1147+
),
1148+
pytest.param(
1149+
{},
1150+
NO_QUERY_STRING,
1151+
id="defaults",
1152+
),
1153+
pytest.param(
1154+
{"_experiments": {"data_collection": {}}},
1155+
"toy=tennisball&color=red&auth=[Filtered]",
1156+
id="data_collection_denylist_default",
1157+
),
1158+
pytest.param(
1159+
{
1160+
"_experiments": {
1161+
"data_collection": {
1162+
"url_query_params": {"mode": "denylist", "terms": ["toy"]}
1163+
}
1164+
}
1165+
},
1166+
"toy=[Filtered]&color=red&auth=[Filtered]",
1167+
id="data_collection_denylist_custom_terms",
1168+
),
1169+
pytest.param(
1170+
{
1171+
"_experiments": {
1172+
"data_collection": {
1173+
"url_query_params": {"mode": "allowlist", "terms": ["toy"]}
1174+
}
1175+
}
1176+
},
1177+
"toy=tennisball&color=[Filtered]&auth=[Filtered]",
1178+
id="data_collection_allowlist",
1179+
),
1180+
pytest.param(
1181+
{
1182+
"_experiments": {
1183+
"data_collection": {
1184+
"url_query_params": {"mode": "allowlist", "terms": ["auth"]}
1185+
}
1186+
}
1187+
},
1188+
"toy=[Filtered]&color=[Filtered]&auth=[Filtered]",
1189+
id="data_collection_allowlist_sensitive_term",
1190+
),
1191+
pytest.param(
1192+
{"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}},
1193+
NO_QUERY_STRING,
1194+
id="data_collection_off",
1195+
),
1196+
pytest.param(
1197+
{
1198+
"send_default_pii": True,
1199+
"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}},
1200+
},
1201+
NO_QUERY_STRING,
1202+
id="data_collection_wins_over_send_default_pii",
1203+
),
1204+
]
1205+
1206+
1207+
@pytest.mark.asyncio
1208+
@pytest.mark.parametrize(
1209+
"init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES
1210+
)
1211+
async def test_span_streaming_url_query_data_collection(
1212+
sentry_init, capture_items, init_kwargs, expected_query
1213+
):
1214+
init_kwargs = dict(init_kwargs)
1215+
experiments = {"trace_lifecycle": "stream"}
1216+
experiments.update(init_kwargs.pop("_experiments", {}))
1217+
sentry_init(
1218+
integrations=[quart_sentry.QuartIntegration()],
1219+
traces_sample_rate=1.0,
1220+
_experiments=experiments,
1221+
**init_kwargs,
1222+
)
1223+
items = capture_items("span")
1224+
1225+
app = quart_app_factory()
1226+
client = app.test_client()
1227+
response = await client.get("/message?toy=tennisball&color=red&auth=secret")
1228+
assert response.status_code == 200
1229+
1230+
sentry_sdk.flush()
1231+
1232+
spans = [item.payload for item in items]
1233+
assert len(spans) == 1
1234+
1235+
segment = spans[0]
1236+
1237+
data_collection_enabled = "data_collection" in experiments
1238+
url_attrs_expected = data_collection_enabled or init_kwargs.get(
1239+
"send_default_pii", False
1240+
)
1241+
1242+
if expected_query is NO_QUERY_STRING:
1243+
assert "url.query" not in segment["attributes"]
1244+
if url_attrs_expected:
1245+
# When the filtered query string is empty, url.full carries the
1246+
# base URL only (no query).
1247+
assert segment["attributes"]["url.full"] == "http://localhost/message"
1248+
else:
1249+
assert "url.full" not in segment["attributes"]
1250+
else:
1251+
assert segment["attributes"]["url.query"] == expected_query
1252+
assert segment["attributes"]["url.full"] == (
1253+
f"http://localhost/message?{expected_query}"
1254+
)
1255+
1256+
1257+
@pytest.mark.asyncio
1258+
async def test_span_streaming_url_query_multi_and_blank_values(
1259+
sentry_init, capture_items
1260+
):
1261+
sentry_init(
1262+
integrations=[quart_sentry.QuartIntegration()],
1263+
traces_sample_rate=1.0,
1264+
_experiments={"trace_lifecycle": "stream", "data_collection": {}},
1265+
)
1266+
items = capture_items("span")
1267+
1268+
app = quart_app_factory()
1269+
client = app.test_client()
1270+
response = await client.get("/message?foo=1&foo=2&empty=")
1271+
assert response.status_code == 200
1272+
1273+
sentry_sdk.flush()
1274+
1275+
spans = [item.payload for item in items]
1276+
assert len(spans) == 1
1277+
1278+
segment = spans[0]
1279+
query = segment["attributes"]["url.query"]
1280+
# Repeated keys are preserved and blank values are kept.
1281+
assert "foo=1" in query
1282+
assert "foo=2" in query
1283+
assert "empty=" in query
1284+
# url.full carries the same filtered query string.
1285+
assert segment["attributes"]["url.full"] == f"http://localhost/message?{query}"

0 commit comments

Comments
 (0)