From 14652a94717581c0f555cd5196efa7b866e5bce5 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 17 Jul 2026 11:08:42 -0400 Subject: [PATCH] feat(gcp): Apply data_collection filtering to URL query strings Refs PY-2583 Refs #6743 --- sentry_sdk/integrations/gcp.py | 35 ++++-- tests/integrations/gcp/test_gcp.py | 171 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/gcp.py b/sentry_sdk/integrations/gcp.py index 91a62b3a81..8d63899751 100644 --- a/sentry_sdk/integrations/gcp.py +++ b/sentry_sdk/integrations/gcp.py @@ -8,6 +8,7 @@ import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations import Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.cloud_resource_context import CLOUD_PROVIDER @@ -20,6 +21,7 @@ TimeoutThread, capture_internal_exceptions, event_from_exception, + has_data_collection_enabled, logger, reraise, ) @@ -100,10 +102,20 @@ def sentry_func( if hasattr(gcp_event, "method"): additional_attributes["http.request.method"] = gcp_event.method - if should_send_default_pii() and hasattr(gcp_event, "query_string"): - additional_attributes["url.query"] = gcp_event.query_string.decode( - "utf-8", errors="replace" - ) + if hasattr(gcp_event, "query_string"): + query_string = gcp_event.query_string.decode("utf-8", errors="replace") + if query_string: + if has_data_collection_enabled(client.options): + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + if filtered_qs: + additional_attributes["url.query"] = filtered_qs + elif should_send_default_pii(): + additional_attributes["url.query"] = query_string sampling_context = { "gcp_env": { @@ -235,9 +247,18 @@ def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": request["method"] = gcp_event.method if hasattr(gcp_event, "query_string"): - request["query_string"] = gcp_event.query_string.decode( - "utf-8", errors="replace" - ) + query_string = gcp_event.query_string.decode("utf-8", errors="replace") + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if query_string: + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_qs: + request["query_string"] = filtered_qs + else: + request["query_string"] = query_string if hasattr(gcp_event, "headers"): request["headers"] = _filter_headers(gcp_event.headers) diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index 86638b1644..7dd9b475a8 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -786,3 +786,174 @@ def cloud_function(functionhandler, event): assert attrs["gcp.project.id"] == "serverless_project" assert attrs["faas.identity"] == "func_ID" assert attrs["faas.entry_point"] == "cloud_function" + + +# Sentinel: the query string (event) / ``url.query`` attribute (span) is absent. +NO_QUERY_STRING = "__NO_QUERY_STRING__" + + +# Each case is (send_default_pii, data_collection, expected_event, expected_span). +# ``send_default_pii`` / ``data_collection`` of None means the option is omitted. +# The two paths only diverge for the legacy (no ``data_collection``) rows: the +# event processor always records the raw query string, while the span-streaming +# path gates the ``url.query`` attribute on ``send_default_pii``. +_QUERY_STRING_DATA_COLLECTION_CASES = [ + pytest.param( + True, + None, + "toy=tennisball&color=red&auth=secret", + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + False, + None, + "toy=tennisball&color=red&auth=secret", + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + None, + None, + "toy=tennisball&color=red&auth=secret", + NO_QUERY_STRING, + id="defaults", + ), + # data_collection configured: query string is routed through filtering. + # Spec defaults -> denylist: only the sensitive ``auth`` is redacted. + pytest.param( + None, + {}, + "toy=tennisball&color=red&auth=[Filtered]", + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + None, + {"url_query_params": {"mode": "denylist", "terms": ["toy"]}}, + "toy=[Filtered]&color=red&auth=[Filtered]", + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + # allowlist with only ``toy`` allowed: ``color`` is redacted even though it + # is not sensitive, proving the redaction comes from the allowlist. + pytest.param( + None, + {"url_query_params": {"mode": "allowlist", "terms": ["toy"]}}, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + None, + {"url_query_params": {"mode": "off"}}, + NO_QUERY_STRING, + NO_QUERY_STRING, + id="data_collection_off", + ), +] + + +def _build_init_kwargs(send_default_pii, data_collection): + """Render the keyword-argument string passed to ``init_sdk`` in the + subprocess.""" + kwargs = [] + if send_default_pii is not None: + kwargs.append("send_default_pii=%r" % send_default_pii) + if data_collection is not None: + kwargs.append("_experiments=%r" % {"data_collection": data_collection}) + + return ", ".join(kwargs) + + +@pytest.mark.parametrize( + "send_default_pii, data_collection, expected_event, expected_span", + _QUERY_STRING_DATA_COLLECTION_CASES, +) +def test_query_string_data_collection_event_processor( + run_cloud_function, + send_default_pii, + data_collection, + expected_event, + expected_span, +): + init_kwargs = _build_init_kwargs(send_default_pii, data_collection) + envelope_items, _, _ = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers", "method", "query_string"]) + event = GCPEvent( + headers={}, + method="GET", + query_string=b"toy=tennisball&color=red&auth=secret", + ) + + def cloud_function(functionhandler, event): + raise Exception("something went wrong") + """ + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(%s) + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + % init_kwargs + ) + ) + + request = envelope_items[0]["request"] + if expected_event == NO_QUERY_STRING: + assert "query_string" not in request + else: + assert request["query_string"] == expected_event + + +@pytest.mark.parametrize( + "send_default_pii, data_collection, expected_event, expected_span", + _QUERY_STRING_DATA_COLLECTION_CASES, +) +def test_query_string_data_collection_span_streaming( + run_cloud_function, + send_default_pii, + data_collection, + expected_event, + expected_span, +): + init_kwargs = _build_init_kwargs(send_default_pii, data_collection) + _, _, span_items = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers", "method", "query_string"]) + event = GCPEvent( + headers={}, + method="GET", + query_string=b"toy=tennisball&color=red&auth=secret", + ) + + def cloud_function(functionhandler, event): + return "ok" + """ + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(traces_sample_rate=1.0, trace_lifecycle="stream", %s) + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + % init_kwargs + ) + ) + + assert len(span_items) == 1 + attrs = span_items[0]["attributes"] + if expected_span == NO_QUERY_STRING: + assert "url.query" not in attrs + else: + assert attrs["url.query"] == expected_span