From 5c8b2c538693238b807998e8f56dfbaf837664f0 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 26 Aug 2026 13:51:47 -0400 Subject: [PATCH 1/2] 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/2] 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", ), ], )