From e4abd0275c4896dc9faf435285ccec42d1d231b4 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 9 Jul 2026 07:56:08 -0400 Subject: [PATCH 1/3] feat(data-collection): gate data collection behind experiment flag Add `enable_data_collection` experiment flag to control the rollout of the `data_collection` client option. Until the experiment is enabled via `_experiments={"enable_data_collection": True}`, the SDK continues to derive collection behaviour from `send_default_pii`. Fixes PY-2603 Fixes #6789 --- sentry_sdk/consts.py | 6 +++++- sentry_sdk/utils.py | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f9700bb6e0..6c9835642c 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -87,6 +87,7 @@ class CompressionAlgo(Enum): Callable[[SpanJSON, Hint], Optional[SpanJSON]] ], "suppress_asgi_chained_exceptions": Optional[bool], + "enable_data_collection": Optional[bool], }, total=False, ) @@ -1435,7 +1436,10 @@ def __init__( managing `Sensitive Data `_. :param data_collection: (EXPERIMENTAL) Structured configuration controlling what data integrations collect automatically, - superseding `send_default_pii`. Pass a dict to enable or + superseding `send_default_pii`. This option only takes effect when the + `enable_data_collection` experiment is turned on via + `_experiments={"enable_data_collection": True}`; otherwise the SDK + continues to derive behaviour from `send_default_pii`. Pass a dict to enable or restrict collection per category (user identity, cookies, HTTP headers/bodies, query params, generative AI inputs/outputs, stack frame variables, source context). diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 4e362cae54..00d7f5b77e 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2080,6 +2080,13 @@ def has_logs_enabled(options: "Optional[dict[str, Any]]") -> bool: ) +def has_data_collection_enabled(options: "Optional[dict[str, Any]]") -> bool: + if options is None: + return False + + return bool(options["_experiments"].get("enable_data_collection", False)) + + def get_before_send_log( options: "Optional[dict[str, Any]]", ) -> "Optional[Callable[[Log, Hint], Optional[Log]]]": From a7e49717e7528053be2703bb3cc97021664410c5 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 9 Jul 2026 09:19:10 -0400 Subject: [PATCH 2/3] ref(data-collection): move data_collection into _experiments Fold `data_collection` under `_experiments` instead of exposing it as a top-level `Client` constructor option, and gate it on presence rather than a separate `enable_data_collection` flag. --- sentry_sdk/consts.py | 45 ++++++++-------- sentry_sdk/data_collection.py | 2 +- sentry_sdk/utils.py | 2 +- tests/test_data_collection.py | 96 ++++++++++++++++++++++++----------- 4 files changed, 88 insertions(+), 57 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 6c9835642c..759898f6ba 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -87,7 +87,7 @@ class CompressionAlgo(Enum): Callable[[SpanJSON, Hint], Optional[SpanJSON]] ], "suppress_asgi_chained_exceptions": Optional[bool], - "enable_data_collection": Optional[bool], + "data_collection": Optional[DataCollectionUserOptions], }, total=False, ) @@ -1280,7 +1280,6 @@ def __init__( transport_queue_size: int = DEFAULT_QUEUE_SIZE, sample_rate: float = 1.0, send_default_pii: "Optional[bool]" = None, - data_collection: "Optional[DataCollectionUserOptions]" = None, http_proxy: "Optional[str]" = None, https_proxy: "Optional[str]" = None, ignore_errors: "Sequence[Union[type, str]]" = [], # noqa: B006 @@ -1435,28 +1434,6 @@ def __init__( If you enable this option, be sure to manually remove what you don't want to send using our features for managing `Sensitive Data `_. - :param data_collection: (EXPERIMENTAL) Structured configuration controlling what data integrations collect automatically, - superseding `send_default_pii`. This option only takes effect when the - `enable_data_collection` experiment is turned on via - `_experiments={"enable_data_collection": True}`; otherwise the SDK - continues to derive behaviour from `send_default_pii`. Pass a dict to enable or - restrict collection per category (user identity, cookies, HTTP headers/bodies, query params, generative AI - inputs/outputs, stack frame variables, source context). - - When `data_collection` is set, omitted fields use their defaults (most categories are collected, with the - sensitive denylist scrubbing values). When it is not set, the SDK derives behaviour from `send_default_pii` - so that upgrading without configuring `data_collection` changes nothing. If both are set, `data_collection` - takes precedence. - - Example:: - - sentry_sdk.init( - dsn="...", - data_collection={"user_info": False, "http_bodies": []}, - ) - - See https://docs.sentry.io/platforms/python/configuration/options/#data_collection for more details. - :param event_scrubber: Scrubs the event payload for sensitive information such as cookies, sessions, and passwords from a `denylist`. @@ -1780,7 +1757,25 @@ def __init__( :param stream_gen_ai_spans: When set, generative AI spans are sent in a new transport format to reduce downstream data loss. - :param _experiments: + :param _experiments: Dictionary of experimental, opt-in features that are not yet stable. + + ``data_collection`` (EXPERIMENTAL): structured configuration controlling what data integrations + collect automatically, superseding `send_default_pii`. Passing a dict under + `_experiments={"data_collection": {...}}` opts into the feature; omitted fields use their + defaults (most categories are collected, with the sensitive denylist scrubbing values). + When it is not set, the SDK derives behaviour from `send_default_pii` so that upgrading + changes nothing. Restrict collection per category (user identity, cookies, HTTP + headers/bodies, query params, generative AI inputs/outputs, stack frame variables, source + context). If `send_default_pii` is also set, `data_collection` takes precedence. + + Example:: + + sentry_sdk.init( + dsn="...", + _experiments={"data_collection": {"user_info": False, "http_bodies": []}}, + ) + + See https://docs.sentry.io/platforms/python/configuration/options/#data_collection for more details. """ pass diff --git a/sentry_sdk/data_collection.py b/sentry_sdk/data_collection.py index e742f3c209..4908b82e9a 100644 --- a/sentry_sdk/data_collection.py +++ b/sentry_sdk/data_collection.py @@ -220,7 +220,7 @@ def _resolve_data_collection(options: "Dict[str, Any]") -> "DataCollection": ``data_collection`` must be a plain ``dict``. """ - user_dc = options.get("data_collection") + user_dc = options.get("_experiments", {}).get("data_collection") send_default_pii = options.get("send_default_pii") include_local_variables = ( diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 00d7f5b77e..0963015351 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2084,7 +2084,7 @@ def has_data_collection_enabled(options: "Optional[dict[str, Any]]") -> bool: if options is None: return False - return bool(options["_experiments"].get("enable_data_collection", False)) + return "data_collection" in options.get("_experiments", {}) def get_before_send_log( diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index fcec0c0e99..ddaf2cae46 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -4,15 +4,20 @@ import sentry_sdk from sentry_sdk.data_collection import _ALL_HTTP_BODY_TYPES +from sentry_sdk.utils import has_data_collection_enabled def test_kvcb_invalid_mode(): with pytest.raises(ValueError): - sentry_sdk.init(data_collection={"cookies": {"mode": "nope"}}) # type: ignore Purposely ignoring to test invalid option + sentry_sdk.init(_experiments={"data_collection": {"cookies": {"mode": "nope"}}}) # type: ignore Purposely ignoring to test invalid option def test_kvcb_from_dict_defaults_mode(): - sentry_sdk.init(data_collection={"cookies": {"mode": "denylist", "terms": ["x"]}}) + sentry_sdk.init( + _experiments={ + "data_collection": {"cookies": {"mode": "denylist", "terms": ["x"]}} + } + ) client = sentry_sdk.get_client() assert client.options["data_collection"]["cookies"] == { "mode": "denylist", @@ -23,13 +28,13 @@ def test_kvcb_from_dict_defaults_mode(): def test_http_headers_collection_defaults(): default_terms = ["forwarded", "-ip", "remote-", "via", "-user"] - sentry_sdk.init(data_collection={"http_headers": {}}) # type: ignore Purposely ignoring to test invalid option + sentry_sdk.init(_experiments={"data_collection": {"http_headers": {}}}) # type: ignore Purposely ignoring to test invalid option client = sentry_sdk.get_client() assert client.options["data_collection"]["http_headers"]["request"] == { "mode": "denylist" } - sentry_sdk.init(data_collection={"http_headers": "off"}) # type: ignore Purposely ignoring to test invalid option + sentry_sdk.init(_experiments={"data_collection": {"http_headers": "off"}}) # type: ignore Purposely ignoring to test invalid option client = sentry_sdk.get_client() assert client.options["data_collection"]["http_headers"]["request"] == { "mode": "denylist" @@ -45,9 +50,11 @@ def test_http_headers_collection_defaults(): def test_http_headers_use_default_in_setting_with_missing_config(): sentry_sdk.init( - data_collection={ - "http_headers": { - "request": {"mode": "allowlist", "terms": ["x-id"]}, + _experiments={ + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["x-id"]}, + } } } ) @@ -108,7 +115,7 @@ def _get(dc, path): id="send_default_pii_false_collects_no_pii", ), pytest.param( - {"data_collection": {}}, + {"_experiments": {"data_collection": {}}}, { "user_info": True, "gen_ai.inputs": True, @@ -121,7 +128,11 @@ def _get(dc, path): id="explicit_data_collection_uses_spec_defaults", ), pytest.param( - {"data_collection": {"user_info": False, "http_bodies": []}}, + { + "_experiments": { + "data_collection": {"user_info": False, "http_bodies": []} + } + }, { "user_info": False, "http_bodies": [], @@ -132,7 +143,7 @@ def _get(dc, path): ), pytest.param( { - "data_collection": {}, + "_experiments": {"data_collection": {}}, "include_local_variables": False, "include_source_context": False, }, @@ -141,11 +152,13 @@ def _get(dc, path): ), pytest.param( { - "data_collection": { - "cookies": {"mode": "off"}, - "query_params": {"mode": "allowlist", "terms": ["page"]}, - "http_headers": {"request": {"mode": "off"}}, - "gen_ai": {"inputs": False, "outputs": True}, + "_experiments": { + "data_collection": { + "cookies": {"mode": "off"}, + "query_params": {"mode": "allowlist", "terms": ["page"]}, + "http_headers": {"request": {"mode": "off"}}, + "gen_ai": {"inputs": False, "outputs": True}, + } } }, { @@ -160,12 +173,14 @@ def _get(dc, path): ), pytest.param( { - "data_collection": { - "cookies": None, - "http_headers": None, - "query_params": None, - "graphql": None, - "gen_ai": None, + "_experiments": { + "data_collection": { + "cookies": None, + "http_headers": None, + "query_params": None, + "graphql": None, + "gen_ai": None, + } } }, { @@ -180,7 +195,7 @@ def _get(dc, path): id="none_values_fall_back_to_spec_defaults", ), pytest.param( - {"data_collection": {}}, + {"_experiments": {"data_collection": {}}}, { "graphql.document": True, "graphql.variables": True, @@ -209,27 +224,27 @@ def _get(dc, path): id="legacy_pii_on_collects_graphql_database_and_queues", ), pytest.param( - {"data_collection": {"graphql": {"variables": False}}}, + {"_experiments": {"data_collection": {"graphql": {"variables": False}}}}, {"graphql.document": True, "graphql.variables": False}, id="explicit_partial_graphql_fills_omitted", ), pytest.param( - {"data_collection": {"frame_context_lines": True}}, + {"_experiments": {"data_collection": {"frame_context_lines": True}}}, {"frame_context_lines": 5}, id="frame_context_lines_bool_fallback_true", ), pytest.param( - {"data_collection": {"frame_context_lines": False}}, + {"_experiments": {"data_collection": {"frame_context_lines": False}}}, {"frame_context_lines": 0}, id="frame_context_lines_bool_fallback_false", ), pytest.param( - {"data_collection": {"frame_context_lines": 3}}, + {"_experiments": {"data_collection": {"frame_context_lines": 3}}}, {"frame_context_lines": 3}, id="frame_context_lines_bool_fallback_3", ), pytest.param( - {"data_collection": {"frame_context_lines": 0}}, + {"_experiments": {"data_collection": {"frame_context_lines": 0}}}, {"frame_context_lines": 0}, id="frame_context_lines_bool_fallback_0", ), @@ -245,7 +260,8 @@ def test_initialize_client_data_collection_overrides_send_default_pii_and_warns( with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") dc = _initialize_client_with_config( - send_default_pii=True, data_collection={"user_info": False} + send_default_pii=True, + _experiments={"data_collection": {"user_info": False}}, ) assert dc["user_info"] is False # data_collection wins assert any(issubclass(w.category, DeprecationWarning) for w in caught) @@ -269,7 +285,7 @@ def test_initialize_client_data_collection_overrides_send_default_pii_and_warns( id="send_default_pii_enables_user_info", ), pytest.param( - {"data_collection": {"user_info": False}}, + {"_experiments": {"data_collection": {"user_info": False}}}, {"user_info": False, "provided_by_user": True}, id="explicit_data_collection_overrides_user_info", ), @@ -279,7 +295,10 @@ def test_initialize_client_data_collection_overrides_send_default_pii_and_warns( id="dsnless_spotlight_rederives_data_collection", ), pytest.param( - {"spotlight": True, "data_collection": {"user_info": False}}, + { + "spotlight": True, + "_experiments": {"data_collection": {"user_info": False}}, + }, {"provided_by_user": True, "user_info": False}, id="dsnless_spotlight_respects_explicit_data_collection", ), @@ -293,3 +312,20 @@ def test_client_data_collection_settings(init_kwargs, expected): assert client.should_send_default_pii() is value else: assert client.options["data_collection"][key] is value + + +def test_has_data_collection_enabled_gates_on_presence(): + assert has_data_collection_enabled(None) is False + assert has_data_collection_enabled({"_experiments": {}}) is False + assert ( + has_data_collection_enabled({"_experiments": {"data_collection": {}}}) is True + ) + + +def test_no_experiments_falls_back_to_send_default_pii(): + sentry_sdk.init(send_default_pii=True) + client = sentry_sdk.get_client() + dc = client.options["data_collection"] + assert dc["provided_by_user"] is False + assert dc["user_info"] is True + assert has_data_collection_enabled(client.options) is False From 2803d5e55a85b4a22b49c9e3f617cd63edb6e847 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 9 Jul 2026 09:22:55 -0400 Subject: [PATCH 3/3] test improvements --- tests/test_data_collection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index ddaf2cae46..fc5a8ea754 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -320,9 +320,12 @@ def test_has_data_collection_enabled_gates_on_presence(): assert ( has_data_collection_enabled({"_experiments": {"data_collection": {}}}) is True ) + assert ( + has_data_collection_enabled({"_experiments": {"data_collection": None}}) is True + ) -def test_no_experiments_falls_back_to_send_default_pii(): +def test_no_experiments_data_collection_values_fall_back_to_send_default_pii_configuration(): sentry_sdk.init(send_default_pii=True) client = sentry_sdk.get_client() dc = client.options["data_collection"]