From 5c8b2c538693238b807998e8f56dfbaf837664f0 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 26 Aug 2026 13:51:47 -0400 Subject: [PATCH 1/3] feat(utils): Respect data_collection.frame_context_lines option Frame source context (pre/post/context lines) now honours the data_collection experimental option's frame_context_lines setting when enabled, overriding both the default of 5 lines and the legacy include_source_context flag. Refs PY-2578 Refs #6738 --- sentry_sdk/utils.py | 13 +++++++ tests/test_utils.py | 84 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 45e5376d1c..7ac5a60447 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -467,7 +467,14 @@ def get_lines_from_file( loader: "Optional[Any]" = None, module: "Optional[str]" = None, ) -> "Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]": + client_options = sentry_sdk.get_client().options + + # This is the default pre-data collection. Should be removed once data collection + # is fully released context_lines = 5 + if has_data_collection_enabled(client_options): + context_lines = client_options["data_collection"]["frame_context_lines"] + source = None if loader is not None and hasattr(loader, "get_source"): try: @@ -607,6 +614,12 @@ def serialize_frame( "lineno": tb_lineno, } + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + include_source_context = bool( + client_options["data_collection"]["frame_context_lines"] + ) + if include_source_context: rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context( frame, tb_lineno, max_value_length diff --git a/tests/test_utils.py b/tests/test_utils.py index 9e5b68e9eb..6e24c3cf04 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -492,16 +492,41 @@ def test_warns_on_invalid_sample_rate(rate, StringContaining): # noqa: N803 @pytest.mark.parametrize( - "include_source_context", - [True, False], + "options,include_source_context,expected_source_context", + [ + pytest.param({}, True, True, id="no_data_collection-include_true"), + pytest.param({}, False, False, id="no_data_collection-include_false"), + pytest.param( + {"_experiments": {"data_collection": {}}}, + False, + True, + id="data_collection-spec_default_overrides_include_false", + ), + pytest.param( + {"_experiments": {"data_collection": {"frame_context_lines": 3}}}, + True, + True, + id="data_collection-frame_context_lines_3", + ), + pytest.param( + {"_experiments": {"data_collection": {"frame_context_lines": 0}}}, + True, + False, + id="data_collection-frame_context_lines_0_overrides_include_true", + ), + ], ) -def test_include_source_context_when_serializing_frame(include_source_context): +def test_include_source_context_when_serializing_frame( + sentry_init, options, include_source_context, expected_source_context +): + sentry_init(**options) + frame = sys._getframe() result = serialize_frame(frame, include_source_context=include_source_context) - assert include_source_context ^ ("pre_context" in result) ^ True - assert include_source_context ^ ("context_line" in result) ^ True - assert include_source_context ^ ("post_context" in result) ^ True + assert ("pre_context" in result) is expected_source_context + assert ("context_line" in result) is expected_source_context + assert ("post_context" in result) is expected_source_context @pytest.mark.parametrize( @@ -1069,6 +1094,53 @@ def fake_getlines(filename): assert result == expected_result +@pytest.mark.parametrize( + "options,expected_context_lines", + [ + pytest.param({}, 5, id="no_data_collection-defaults_to_5"), + pytest.param( + {"_experiments": {"data_collection": {}}}, + 5, + id="data_collection-spec_default_5", + ), + pytest.param( + {"_experiments": {"data_collection": {"frame_context_lines": 3}}}, + 3, + id="data_collection-frame_context_lines_3", + ), + pytest.param( + {"_experiments": {"data_collection": {"frame_context_lines": 0}}}, + 0, + id="data_collection-frame_context_lines_0", + ), + pytest.param( + { + "_experiments": {"data_collection": {}}, + "include_source_context": False, + }, + 0, + id="data_collection-legacy_include_source_context_false", + ), + ], +) +def test_get_lines_from_file_frame_context_lines( + sentry_init, options, expected_context_lines +): + source = ["line{}\n".format(i) for i in range(20)] + + sentry_init(**options) + + def fake_getlines(filename): + return source + + with mock.patch("sentry_sdk.utils.linecache.getlines", fake_getlines): + pre_context, context_line, post_context = get_lines_from_file("filename", 10) + + assert context_line == "line10" + assert len(pre_context) == expected_context_lines + assert len(post_context) == expected_context_lines + + def test_safe_serialize_plain_string(): assert safe_serialize("already a string") == "already a string" From df3e2339bf7c8ce8d258417c7ed1092db05955a9 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 26 Aug 2026 14:19:02 -0400 Subject: [PATCH 2/3] Remove reference to legacy variable when data collection is explicitly provided --- sentry_sdk/data_collection.py | 9 ++------- tests/test_data_collection.py | 4 ++-- tests/test_utils.py | 4 ++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/data_collection.py b/sentry_sdk/data_collection.py index 4b130da1fb..6af061e3a0 100644 --- a/sentry_sdk/data_collection.py +++ b/sentry_sdk/data_collection.py @@ -191,22 +191,18 @@ def _map_from_send_default_pii( def _resolve_explicit( d: "dict[str, Any]", include_local_variables: bool, - include_source_context: bool, ) -> "DataCollection": """ Build a fully-resolved ``DataCollection`` from a user-supplied ``data_collection`` dict, filling in spec defaults for any omitted or - partially-specified field. Frame fields fall back to the legacy - ``include_local_variables`` / ``include_source_context`` options when unset. + partially-specified field. """ # frame_context_lines accepts an integer or a boolean fallback (spec: True # -> platform default of 5, False -> 0). bool is a subclass of int, so # coerce explicitly before treating it as a line count. frame_context_lines = d.get("frame_context_lines") if frame_context_lines is None: - frame_context_lines = ( - _DEFAULT_FRAME_CONTEXT_LINES if include_source_context else 0 - ) + 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 @@ -324,7 +320,6 @@ def _resolve_data_collection(options: "Dict[str, Any]") -> "DataCollection": return _resolve_explicit( user_dc, include_local_variables, - include_source_context, ) return _map_from_send_default_pii( diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index f2bb32dcde..f1818c5e98 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -147,8 +147,8 @@ def _get(dc, path): "include_local_variables": False, "include_source_context": False, }, - {"stack_frame_variables": False, "frame_context_lines": 0}, - id="explicit_frame_fields_fall_back_to_legacy_options", + {"stack_frame_variables": False, "frame_context_lines": 5}, + id="explicit_stack_frame_variables_falls_back_to_legacy_option", ), pytest.param( { diff --git a/tests/test_utils.py b/tests/test_utils.py index 6e24c3cf04..f2f6c517b7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1118,8 +1118,8 @@ def fake_getlines(filename): "_experiments": {"data_collection": {}}, "include_source_context": False, }, - 0, - id="data_collection-legacy_include_source_context_false", + 5, + id="data_collection-spec_default_overrides_include_source_context_false", ), ], ) From dc5822cc1591fef7c5716c621ae1d81fdf619324 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 26 Aug 2026 15:22:47 -0400 Subject: [PATCH 3/3] 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):