Skip to content

Commit 3826c55

Browse files
authored
feat(boto3): Support data_collection filtering for URL query params (#7290)
Previously the boto3 integration only redacted URL query params and fragments based on send_default_pii, always leaking the full query string when PII collection was enabled. Now the data_collection experiment's url_query_params allow/deny list is applied to the span-streaming span attributes and to breadcrumbs, matching the behaviour already shipped for httpx and pyreqwest. url.full now includes the filtered query string and fragment, and url.query / url.fragment are omitted rather than reported as empty strings when the request URL has none. The legacy (non span-streaming) span path is left as-is. Fixes PY-2745 Fixes #7280
1 parent cdbf53b commit 3826c55

2 files changed

Lines changed: 204 additions & 21 deletions

File tree

sentry_sdk/integrations/boto3.py

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
import sentry_sdk
55
from sentry_sdk.consts import OP, SPANDATA
6+
from sentry_sdk.data_collection import (
7+
_apply_data_collection_filtering_to_query_string,
8+
)
69
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
710
from sentry_sdk.scope import should_send_default_pii
811
from sentry_sdk.traces import StreamedSpan
@@ -15,6 +18,7 @@
1518
)
1619
from sentry_sdk.utils import (
1720
capture_internal_exceptions,
21+
has_data_collection_enabled,
1822
parse_url,
1923
parse_version,
2024
)
@@ -24,6 +28,10 @@
2428

2529
from botocore.model import ServiceId
2630

31+
from sentry_sdk._types import Attributes
32+
from sentry_sdk.client import BaseClient as SentryClient
33+
from sentry_sdk.utils import ParsedUrl
34+
2735
try:
2836
from botocore import __version__ as BOTOCORE_VERSION
2937
from botocore.awsrequest import AWSRequest
@@ -62,6 +70,40 @@ def sentry_patched_init(
6270
BaseClient.__init__ = sentry_patched_init # type: ignore
6371

6472

73+
def _get_url_attributes(
74+
client: "SentryClient", parsed_url: "Optional[ParsedUrl]"
75+
) -> "Attributes":
76+
attributes: "Attributes" = {}
77+
if parsed_url is None:
78+
return attributes
79+
80+
query: "Optional[str]"
81+
if has_data_collection_enabled(client.options):
82+
query = None
83+
if parsed_url.query:
84+
query = _apply_data_collection_filtering_to_query_string(
85+
query_string=parsed_url.query,
86+
behaviour=client.options["data_collection"]["url_query_params"],
87+
)
88+
elif should_send_default_pii():
89+
query = parsed_url.query
90+
else:
91+
return attributes
92+
93+
url_full = parsed_url.url
94+
if query:
95+
attributes[SPANDATA.URL_QUERY] = query
96+
url_full += "?" + query
97+
98+
if parsed_url.fragment:
99+
attributes[SPANDATA.URL_FRAGMENT] = parsed_url.fragment
100+
url_full += "#" + parsed_url.fragment
101+
102+
attributes[SPANDATA.URL_FULL] = url_full
103+
104+
return attributes
105+
106+
65107
def _sentry_request_created(
66108
service_id: "ServiceId", request: "AWSRequest", operation_name: str, **kwargs: "Any"
67109
) -> None:
@@ -81,14 +123,8 @@ def _sentry_request_created(
81123
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
82124
span: "Union[Span, StreamedSpan, None]" = None
83125
if is_span_streaming_enabled:
84-
if parsed_url and should_send_default_pii():
85-
breadcrumb.update(
86-
{
87-
SPANDATA.URL_FULL: parsed_url.url,
88-
SPANDATA.URL_QUERY: parsed_url.query,
89-
SPANDATA.URL_FRAGMENT: parsed_url.fragment,
90-
}
91-
)
126+
url_attributes = _get_url_attributes(client, parsed_url)
127+
breadcrumb.update(url_attributes)
92128

93129
if request.method is not None:
94130
breadcrumb[SPANDATA.HTTP_REQUEST_METHOD] = request.method
@@ -102,14 +138,7 @@ def _sentry_request_created(
102138
SPANDATA.RPC_METHOD: f"{service_id}/{operation_name}",
103139
},
104140
)
105-
if parsed_url and should_send_default_pii():
106-
span.set_attributes(
107-
{
108-
SPANDATA.URL_FULL: parsed_url.url,
109-
SPANDATA.URL_QUERY: parsed_url.query,
110-
SPANDATA.URL_FRAGMENT: parsed_url.fragment,
111-
}
112-
)
141+
span.set_attributes(url_attributes)
113142

114143
if request.method is not None:
115144
span.set_attribute(SPANDATA.HTTP_REQUEST_METHOD, request.method)

tests/integrations/boto3/test_s3.py

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,12 @@ def test_streaming(
131131
}
132132
if send_default_pii:
133133
expected_attrs["url.full"] = "https://bucket.s3.amazonaws.com/foo.pdf"
134-
expected_attrs["url.fragment"] = ""
135-
expected_attrs["url.query"] = ""
136134
assert span1["attributes"] == ApproxDict(expected_attrs)
137135

136+
assert "url.fragment" not in span1["attributes"]
137+
assert "url.query" not in span1["attributes"]
138138
if not send_default_pii:
139139
assert "url.full" not in span1["attributes"]
140-
assert "url.fragment" not in span1["attributes"]
141-
assert "url.query" not in span1["attributes"]
142140

143141
span2 = spans[1]
144142
assert span2["attributes"]["sentry.op"] == "http.client.stream"
@@ -426,9 +424,9 @@ def test_breadcrumb_span_streaming(sentry_init, capture_events, send_default_pii
426424
SPANDATA.URL_FULL: mock.ANY,
427425
SPANDATA.HTTP_REQUEST_METHOD: "GET",
428426
SPANDATA.URL_QUERY: mock.ANY,
429-
SPANDATA.URL_FRAGMENT: "",
430427
}
431428
)
429+
assert SPANDATA.URL_FRAGMENT not in crumb["data"]
432430
else:
433431
assert crumb["data"] == ApproxDict(
434432
{
@@ -438,3 +436,159 @@ def test_breadcrumb_span_streaming(sentry_init, capture_events, send_default_pii
438436
assert SPANDATA.URL_FULL not in crumb["data"]
439437
assert SPANDATA.URL_QUERY not in crumb["data"]
440438
assert SPANDATA.URL_FRAGMENT not in crumb["data"]
439+
440+
441+
BUCKET_URL = "https://bucket.s3.amazonaws.com/"
442+
443+
# ``expected_query`` of ``None`` means no URL data is recorded at all; ``""``
444+
# means the URL is recorded without a query string.
445+
# Structure of the parameters is "init_kwargs, expected_query"
446+
URL_QUERY_PARAMS = [
447+
pytest.param(
448+
{"send_default_pii": True},
449+
"list-type=2&prefix=foo&continuation-token=abc&encoding-type=url",
450+
id="send_default_pii_true",
451+
),
452+
pytest.param(
453+
{"send_default_pii": False},
454+
None,
455+
id="send_default_pii_false",
456+
),
457+
pytest.param(
458+
{},
459+
None,
460+
id="defaults",
461+
),
462+
pytest.param(
463+
{"_experiments": {"data_collection": {}}},
464+
"list-type=2&prefix=foo&continuation-token=%5BFiltered%5D&encoding-type=url",
465+
id="data_collection_denylist_default",
466+
),
467+
pytest.param(
468+
{
469+
"_experiments": {
470+
"data_collection": {
471+
"url_query_params": {"mode": "denylist", "terms": ["prefix"]}
472+
}
473+
}
474+
},
475+
"list-type=2&prefix=%5BFiltered%5D&continuation-token=%5BFiltered%5D&encoding-type=url",
476+
id="data_collection_denylist_custom_terms",
477+
),
478+
pytest.param(
479+
{
480+
"_experiments": {
481+
"data_collection": {
482+
"url_query_params": {"mode": "allowlist", "terms": ["prefix"]}
483+
}
484+
}
485+
},
486+
"list-type=%5BFiltered%5D&prefix=foo&continuation-token=%5BFiltered%5D&encoding-type=%5BFiltered%5D",
487+
id="data_collection_allowlist",
488+
),
489+
pytest.param(
490+
{
491+
"_experiments": {
492+
"data_collection": {
493+
"url_query_params": {
494+
"mode": "allowlist",
495+
"terms": ["continuation-token"],
496+
}
497+
}
498+
}
499+
},
500+
"list-type=%5BFiltered%5D&prefix=%5BFiltered%5D&continuation-token=%5BFiltered%5D&encoding-type=%5BFiltered%5D",
501+
id="data_collection_allowlist_sensitive_term",
502+
),
503+
pytest.param(
504+
{"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}},
505+
"",
506+
id="data_collection_off",
507+
),
508+
pytest.param(
509+
{
510+
"send_default_pii": True,
511+
"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}},
512+
},
513+
"",
514+
id="data_collection_wins_over_send_default_pii",
515+
),
516+
]
517+
518+
519+
@pytest.mark.parametrize("init_kwargs, expected_query", URL_QUERY_PARAMS)
520+
def test_url_query_data_collection_span_streaming(
521+
sentry_init, capture_items, init_kwargs, expected_query
522+
):
523+
sentry_init(
524+
traces_sample_rate=1.0,
525+
integrations=[Boto3Integration()],
526+
default_integrations=False,
527+
trace_lifecycle="stream",
528+
**init_kwargs,
529+
)
530+
531+
client = session.client("s3")
532+
533+
items = capture_items("span")
534+
535+
with sentry_sdk.traces.start_span(name="custom parent"), MockResponse(
536+
client, 200, {}, read_fixture("s3_list.xml")
537+
):
538+
client.list_objects_v2(Bucket="bucket", Prefix="foo", ContinuationToken="abc")
539+
540+
sentry_sdk.flush()
541+
542+
(span,) = [
543+
item.payload
544+
for item in items
545+
if item.payload["attributes"].get("sentry.op") == "http.client"
546+
]
547+
548+
if expected_query is None:
549+
assert SPANDATA.URL_QUERY not in span["attributes"]
550+
assert SPANDATA.URL_FULL not in span["attributes"]
551+
elif expected_query == "":
552+
assert SPANDATA.URL_QUERY not in span["attributes"]
553+
assert span["attributes"][SPANDATA.URL_FULL] == BUCKET_URL
554+
else:
555+
assert span["attributes"][SPANDATA.URL_QUERY] == expected_query
556+
assert (
557+
span["attributes"][SPANDATA.URL_FULL] == BUCKET_URL + "?" + expected_query
558+
)
559+
560+
561+
@pytest.mark.parametrize("init_kwargs, expected_query", URL_QUERY_PARAMS)
562+
def test_url_query_data_collection_breadcrumb(
563+
sentry_init, capture_events, init_kwargs, expected_query
564+
):
565+
sentry_init(
566+
integrations=[Boto3Integration()],
567+
default_integrations=False,
568+
trace_lifecycle="stream",
569+
**init_kwargs,
570+
)
571+
572+
client = session.client("s3")
573+
574+
events = capture_events()
575+
576+
with sentry_sdk.traces.start_span(name="custom parent"), MockResponse(
577+
client, 200, {}, read_fixture("s3_list.xml")
578+
):
579+
client.list_objects_v2(Bucket="bucket", Prefix="foo", ContinuationToken="abc")
580+
581+
capture_message("Testing!")
582+
583+
(event,) = events
584+
(crumb,) = event["breadcrumbs"]["values"]
585+
586+
if expected_query is None:
587+
assert SPANDATA.URL_QUERY not in crumb["data"]
588+
assert SPANDATA.URL_FULL not in crumb["data"]
589+
elif expected_query == "":
590+
assert SPANDATA.URL_QUERY not in crumb["data"]
591+
assert crumb["data"][SPANDATA.URL_FULL] == BUCKET_URL
592+
else:
593+
assert crumb["data"][SPANDATA.URL_QUERY] == expected_query
594+
assert crumb["data"][SPANDATA.URL_FULL] == BUCKET_URL + "?" + expected_query

0 commit comments

Comments
 (0)