diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f9700bb6e0..759898f6ba 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], + "data_collection": Optional[DataCollectionUserOptions], }, total=False, ) @@ -1279,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 @@ -1434,25 +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`. 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`. @@ -1776,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 4e362cae54..0963015351 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 "data_collection" in options.get("_experiments", {}) + + def get_before_send_log( options: "Optional[dict[str, Any]]", ) -> "Optional[Callable[[Log, Hint], Optional[Log]]]": diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index fcec0c0e99..fc5a8ea754 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,23 @@ 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 + ) + assert ( + has_data_collection_enabled({"_experiments": {"data_collection": None}}) is True + ) + + +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"] + assert dc["provided_by_user"] is False + assert dc["user_info"] is True + assert has_data_collection_enabled(client.options) is False