From dc5822cc1591fef7c5716c621ae1d81fdf619324 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 26 Aug 2026 15:22:47 -0400 Subject: [PATCH] feat(data_collection): Support KeyValueCollectionBehaviour for stack_frame_variables Allow `stack_frame_variables` to accept an allowlist/denylist dict (like other data_collection options) in addition to a plain bool. Explicit `data_collection` config now ignores the legacy `include_local_variables` option instead of falling back to it, and `frame_context_lines` validates its value and raises on invalid input. Refs PY-2578 Refs #6738 --- sentry_sdk/_types.py | 4 +- sentry_sdk/data_collection.py | 21 ++++--- tests/test_data_collection.py | 109 +++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 59e501d7da..fa6cf656df 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -188,7 +188,7 @@ class DataCollectionUserOptions(TypedDict, total=False): gen_ai: "GenAICollectionUserOptions" database_query_data: bool queues: bool - stack_frame_variables: bool + stack_frame_variables: "Union[bool, KeyValueCollectionBehaviour]" frame_context_lines: int class DataCollection(TypedDict): @@ -202,7 +202,7 @@ class DataCollection(TypedDict): gen_ai: "GenAICollectionBehaviour" database_query_data: bool queues: bool - stack_frame_variables: bool + stack_frame_variables: "Union[bool, KeyValueCollectionBehaviour]" frame_context_lines: int # "critical" is an alias of "fatal" recognized by Relay diff --git a/sentry_sdk/data_collection.py b/sentry_sdk/data_collection.py index 6af061e3a0..f31f4fa36b 100644 --- a/sentry_sdk/data_collection.py +++ b/sentry_sdk/data_collection.py @@ -23,14 +23,12 @@ """ import warnings -from typing import TYPE_CHECKING, List, Mapping, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Union, cast from urllib.parse import parse_qs, urlencode from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE if TYPE_CHECKING: - from typing import Any, Dict - from sentry_sdk._types import ( DataCollection, GenAICollectionBehaviour, @@ -190,7 +188,6 @@ def _map_from_send_default_pii( def _resolve_explicit( d: "dict[str, Any]", - include_local_variables: bool, ) -> "DataCollection": """ Build a fully-resolved ``DataCollection`` from a user-supplied @@ -205,10 +202,19 @@ def _resolve_explicit( frame_context_lines = _DEFAULT_FRAME_CONTEXT_LINES elif isinstance(frame_context_lines, bool): frame_context_lines = _DEFAULT_FRAME_CONTEXT_LINES if frame_context_lines else 0 + else: + if not isinstance(frame_context_lines, int) or frame_context_lines < 0: + raise ValueError( + "Invalid `frame_context_lines` value: Must be 0 or greater." + ) + + raw_stack_frame_variables = d.get("stack_frame_variables", True) + stack_frame_variables: "Union[bool, KeyValueCollectionBehaviour]" - stack_frame_variables = d.get("stack_frame_variables") - if stack_frame_variables is None: - stack_frame_variables = include_local_variables + if isinstance(raw_stack_frame_variables, dict): + stack_frame_variables = _kvcb_from_value(raw_stack_frame_variables) + else: + stack_frame_variables = bool(raw_stack_frame_variables) # http_bodies: omitted means "all valid types"; [] is the explicit opt-out. http_bodies = d.get("http_bodies") @@ -319,7 +325,6 @@ def _resolve_data_collection(options: "Dict[str, Any]") -> "DataCollection": ) return _resolve_explicit( user_dc, - include_local_variables, ) return _map_from_send_default_pii( diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index f1818c5e98..0d1525de47 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -12,6 +12,32 @@ def test_kvcb_invalid_mode(): sentry_sdk.init(_experiments={"data_collection": {"cookies": {"mode": "nope"}}}) # type: ignore Purposely ignoring to test invalid option +def test_stack_frame_variables_invalid_mode(): + with pytest.raises(ValueError): + sentry_sdk.init( + _experiments={ + "data_collection": {"stack_frame_variables": {"mode": "nope"}} + } + ) + + +@pytest.mark.parametrize( + "value", + ["3", -1, [1], 2.5], + ids=[ + "frame_context_lines_string", + "frame_context_lines_negative", + "frame_context_lines_list", + "frame_context_lines_float", + ], +) +def test_frame_context_lines_invalid_value(value): + with pytest.raises(ValueError): + sentry_sdk.init( + _experiments={"data_collection": {"frame_context_lines": value}} + ) + + def test_kvcb_from_dict_defaults_mode(): sentry_sdk.init( _experiments={ @@ -147,8 +173,8 @@ def _get(dc, path): "include_local_variables": False, "include_source_context": False, }, - {"stack_frame_variables": False, "frame_context_lines": 5}, - id="explicit_stack_frame_variables_falls_back_to_legacy_option", + {"stack_frame_variables": True, "frame_context_lines": 5}, + id="explicit_data_collection_ignores_legacy_include_local_variables", ), pytest.param( { @@ -248,6 +274,85 @@ def _get(dc, path): {"frame_context_lines": 0}, id="frame_context_lines_bool_fallback_0", ), + pytest.param( + {"_experiments": {"data_collection": {"stack_frame_variables": True}}}, + {"stack_frame_variables": True}, + id="stack_frame_variables_explicit_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"stack_frame_variables": False}}}, + {"stack_frame_variables": False}, + id="stack_frame_variables_explicit_false", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "stack_frame_variables": { + "mode": "allowlist", + "terms": ["order_id"], + } + } + } + }, + { + "stack_frame_variables": { + "mode": "allowlist", + "terms": ["order_id"], + } + }, + id="stack_frame_variables_allowlist_dict", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "stack_frame_variables": {"terms": ["order_id"]} + } + } + }, + { + "stack_frame_variables": { + "mode": "denylist", + "terms": ["order_id"], + } + }, + id="stack_frame_variables_dict_defaults_mode_to_denylist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"stack_frame_variables": {"mode": "off"}} + } + }, + {"stack_frame_variables": {"mode": "off"}}, + id="stack_frame_variables_off_dict_omits_terms", + ), + pytest.param( + {"_experiments": {"data_collection": {"stack_frame_variables": "yes"}}}, + {"stack_frame_variables": True}, + id="stack_frame_variables_non_bool_truthy_coerces_to_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"stack_frame_variables": ""}}}, + {"stack_frame_variables": False}, + id="stack_frame_variables_non_bool_falsy_coerces_to_false", + ), + pytest.param( + {"_experiments": {"data_collection": {"frame_context_lines": None}}}, + {"frame_context_lines": 5}, + id="frame_context_lines_none_falls_back_to_spec_default", + ), + pytest.param( + {"include_local_variables": False, "include_source_context": False}, + {"stack_frame_variables": False, "frame_context_lines": 0}, + id="legacy_include_local_variables_off_disables_stack_frame_variables", + ), + pytest.param( + {"include_local_variables": True, "include_source_context": True}, + {"stack_frame_variables": True, "frame_context_lines": 5}, + id="legacy_include_local_variables_on_enables_stack_frame_variables", + ), ], ) def test_initialize_client_data_collection(options, expected):