Skip to content

Commit 8e6d73b

Browse files
authored
feat(utils): Filter stack frame variables via data_collection option (#7274)
serialize_frame now respects the KeyValueCollectionBehaviour configured for stack_frame_variables in the data_collection experiment, applying allowlist/denylist filtering to frame locals before serialization. This takes precedence over the include_local_variables option when data_collection is enabled. Refs PY-2578 Refs #6738
1 parent a9f7946 commit 8e6d73b

3 files changed

Lines changed: 281 additions & 9 deletions

File tree

sentry_sdk/data_collection.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,6 @@ def _resolve_explicit(
194194
``data_collection`` dict, filling in spec defaults for any omitted or
195195
partially-specified field.
196196
"""
197-
# frame_context_lines accepts an integer or a boolean fallback (spec: True
198-
# -> platform default of 5, False -> 0). bool is a subclass of int, so
199-
# coerce explicitly before treating it as a line count.
200197
frame_context_lines = d.get("frame_context_lines")
201198
if frame_context_lines is None:
202199
frame_context_lines = _DEFAULT_FRAME_CONTEXT_LINES
@@ -211,10 +208,11 @@ def _resolve_explicit(
211208
raw_stack_frame_variables = d.get("stack_frame_variables", True)
212209
stack_frame_variables: "Union[bool, KeyValueCollectionBehaviour]"
213210

214-
if isinstance(raw_stack_frame_variables, dict):
215-
stack_frame_variables = _kvcb_from_value(raw_stack_frame_variables)
216-
else:
217-
stack_frame_variables = bool(raw_stack_frame_variables)
211+
stack_frame_variables = (
212+
_kvcb_from_value(raw_stack_frame_variables)
213+
if isinstance(raw_stack_frame_variables, dict)
214+
else bool(raw_stack_frame_variables)
215+
)
218216

219217
# http_bodies: omitted means "all valid types"; [] is the explicit opt-out.
220218
http_bodies = d.get("http_bodies")

sentry_sdk/utils.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from numbers import Real
2020
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit
2121

22+
from sentry_sdk.data_collection import _apply_key_value_collection_filtering
23+
2224
try:
2325
# Python 3.11
2426
from builtins import BaseExceptionGroup
@@ -589,6 +591,8 @@ def serialize_frame(
589591
max_value_length: "Optional[int]" = None,
590592
custom_repr: "Optional[Callable[..., Optional[str]]]" = None,
591593
) -> "Dict[str, Any]":
594+
from sentry_sdk.serializer import serialize
595+
592596
f_code = getattr(frame, "f_code", None)
593597
if not f_code:
594598
abs_path = None
@@ -628,9 +632,30 @@ def serialize_frame(
628632
frame, tb_lineno, max_value_length
629633
)
630634

631-
if include_local_variables:
632-
from sentry_sdk.serializer import serialize
635+
if has_data_collection_enabled(client_options):
636+
dc_stack_frame_vars_config = client_options["data_collection"][
637+
"stack_frame_variables"
638+
]
639+
640+
if isinstance(dc_stack_frame_vars_config, bool):
641+
if dc_stack_frame_vars_config:
642+
rv["vars"] = serialize(
643+
dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
644+
)
645+
else:
646+
local_variables_to_send = _apply_key_value_collection_filtering(
647+
items=dict(frame.f_locals),
648+
behaviour=dc_stack_frame_vars_config,
649+
)
650+
651+
if local_variables_to_send:
652+
serialized_variables = serialize(
653+
local_variables_to_send, is_vars=True, custom_repr=custom_repr
654+
)
655+
656+
rv["vars"] = serialized_variables
633657

658+
elif include_local_variables:
634659
rv["vars"] = serialize(
635660
dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
636661
)

tests/test_utils.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,255 @@ def test_include_source_context_when_serializing_frame(
529529
assert ("post_context" in result) is expected_source_context
530530

531531

532+
def _frame_with_locals():
533+
safe_value = "not sensitive" # noqa: F841
534+
password = "ada123" # noqa: F841
535+
api_key = "abc123" # noqa: F841
536+
nickname = "Beans" # noqa: F841
537+
return sys._getframe()
538+
539+
540+
@pytest.mark.parametrize(
541+
"data_collection,include_local_variables,expected_vars",
542+
[
543+
pytest.param(
544+
{"stack_frame_variables": True},
545+
False,
546+
True,
547+
id="data_collection_stack_frame_variables_true_overrides_include_false",
548+
),
549+
pytest.param(
550+
{"stack_frame_variables": False},
551+
True,
552+
False,
553+
id="data_collection_stack_frame_variables_false_overrides_include_true",
554+
),
555+
pytest.param(
556+
{},
557+
False,
558+
True,
559+
id="data_collection_stack_frame_variables_spec_default_is_true",
560+
),
561+
],
562+
)
563+
def test_stack_frame_variables_bool_when_serializing_frame(
564+
sentry_init, data_collection, include_local_variables, expected_vars
565+
):
566+
sentry_init(_experiments={"data_collection": data_collection})
567+
568+
result = serialize_frame(
569+
_frame_with_locals(), include_local_variables=include_local_variables
570+
)
571+
572+
assert ("vars" in result) is expected_vars
573+
574+
575+
def test_stack_frame_variables_true_does_not_filter_sensitive_locals(sentry_init):
576+
sentry_init(_experiments={"data_collection": {"stack_frame_variables": True}})
577+
578+
result = serialize_frame(_frame_with_locals())
579+
580+
assert result["vars"]["safe_value"] == "'not sensitive'"
581+
assert result["vars"]["password"] == "'ada123'"
582+
583+
584+
@pytest.mark.parametrize(
585+
"behaviour,expected_vars",
586+
[
587+
pytest.param(
588+
{"mode": "denylist"},
589+
{
590+
"safe_value": "'not sensitive'",
591+
"password": "'[Filtered]'",
592+
"api_key": "'[Filtered]'",
593+
"nickname": "'Beans'",
594+
},
595+
id="data_collection_stack_frame_variables_denylist_builtin_terms_only",
596+
),
597+
pytest.param(
598+
{"mode": "denylist", "terms": ["nickname"]},
599+
{
600+
"safe_value": "'not sensitive'",
601+
"password": "'[Filtered]'",
602+
"api_key": "'[Filtered]'",
603+
"nickname": "'[Filtered]'",
604+
},
605+
id="data_collection_stack_frame_variables_denylist_user_terms",
606+
),
607+
pytest.param(
608+
{"mode": "allowlist", "terms": ["safe"]},
609+
{
610+
"safe_value": "'not sensitive'",
611+
"password": "'[Filtered]'",
612+
"api_key": "'[Filtered]'",
613+
"nickname": "'[Filtered]'",
614+
},
615+
id="data_collection_stack_frame_variables_allowlist_user_terms",
616+
),
617+
pytest.param(
618+
{"mode": "allowlist", "terms": ["safe", "api_key"]},
619+
{
620+
"safe_value": "'not sensitive'",
621+
"password": "'[Filtered]'",
622+
"api_key": "'[Filtered]'",
623+
"nickname": "'[Filtered]'",
624+
},
625+
id="data_collection_stack_frame_variables_allowlist_cannot_allow_sensitive_term",
626+
),
627+
],
628+
)
629+
def test_stack_frame_variables_filtering_when_serializing_frame(
630+
sentry_init, behaviour, expected_vars
631+
):
632+
sentry_init(_experiments={"data_collection": {"stack_frame_variables": behaviour}})
633+
634+
result = serialize_frame(_frame_with_locals())
635+
636+
assert result["vars"] == expected_vars
637+
638+
639+
def test_stack_frame_variables_off_omits_vars(sentry_init):
640+
sentry_init(
641+
_experiments={"data_collection": {"stack_frame_variables": {"mode": "off"}}}
642+
)
643+
644+
result = serialize_frame(_frame_with_locals())
645+
646+
assert "vars" not in result
647+
648+
649+
def test_stack_frame_variables_omits_vars_when_frame_has_no_locals(sentry_init):
650+
def _frame_without_locals():
651+
return sys._getframe()
652+
653+
sentry_init(
654+
_experiments={
655+
"data_collection": {"stack_frame_variables": {"mode": "denylist"}}
656+
}
657+
)
658+
659+
result = serialize_frame(_frame_without_locals())
660+
661+
assert "vars" not in result
662+
663+
664+
def test_stack_frame_variables_filtering_uses_custom_repr(sentry_init):
665+
sentry_init(
666+
_experiments={
667+
"data_collection": {"stack_frame_variables": {"mode": "denylist"}}
668+
}
669+
)
670+
671+
def custom_repr(value):
672+
return "CUSTOM" if value == "not sensitive" else None
673+
674+
result = serialize_frame(_frame_with_locals(), custom_repr=custom_repr)
675+
676+
assert result["vars"]["safe_value"] == "CUSTOM"
677+
assert result["vars"]["password"] == "'[Filtered]'"
678+
679+
680+
@pytest.mark.parametrize(
681+
"options,include_local_variables,expected_vars",
682+
[
683+
pytest.param(
684+
{},
685+
True,
686+
True,
687+
id="no_data_collection-include_local_variables_true",
688+
),
689+
pytest.param(
690+
{},
691+
False,
692+
False,
693+
id="no_data_collection-include_local_variables_false",
694+
),
695+
],
696+
)
697+
def test_include_local_variables_when_data_collection_is_unset(
698+
sentry_init, options, include_local_variables, expected_vars
699+
):
700+
sentry_init(**options)
701+
702+
result = serialize_frame(
703+
_frame_with_locals(), include_local_variables=include_local_variables
704+
)
705+
706+
assert ("vars" in result) is expected_vars
707+
708+
709+
def test_data_collection_stack_frame_variables_overrides_include_local_variables_option(
710+
sentry_init, capture_events
711+
):
712+
sentry_init(
713+
include_local_variables=False,
714+
_experiments={"data_collection": {"stack_frame_variables": True}},
715+
)
716+
events = capture_events()
717+
718+
def raise_with_locals():
719+
safe_value = "not sensitive" # noqa: F841
720+
raise ValueError("boom")
721+
722+
try:
723+
raise_with_locals()
724+
except ValueError:
725+
sentry_sdk.capture_exception()
726+
727+
(event,) = events
728+
frame = event["exception"]["values"][0]["stacktrace"]["frames"][-1]
729+
assert frame["vars"]["safe_value"] == "'not sensitive'"
730+
731+
732+
def test_data_collection_stack_frame_variables_filtering_applies_to_captured_exception(
733+
sentry_init, capture_events
734+
):
735+
sentry_init(
736+
_experiments={
737+
"data_collection": {
738+
"stack_frame_variables": {"mode": "denylist", "terms": ["nickname"]}
739+
}
740+
}
741+
)
742+
events = capture_events()
743+
744+
def raise_with_locals():
745+
safe_value = "not sensitive" # noqa: F841
746+
password = "hunter2" # noqa: F841
747+
nickname = "Bugsy" # noqa: F841
748+
raise ValueError("boom")
749+
750+
try:
751+
raise_with_locals()
752+
except ValueError:
753+
sentry_sdk.capture_exception()
754+
755+
(event,) = events
756+
frame = event["exception"]["values"][0]["stacktrace"]["frames"][-1]
757+
758+
assert frame["vars"]["safe_value"] == "'not sensitive'"
759+
assert frame["vars"]["password"] == "'[Filtered]'"
760+
assert frame["vars"]["nickname"] == "'[Filtered]'"
761+
762+
763+
def test_serialize_frame_variables_serializer_failure(sentry_init):
764+
sentry_init(
765+
_experiments={
766+
"data_collection": {
767+
"stack_frame_variables": {"mode": "denylist", "terms": ["password"]}
768+
}
769+
}
770+
)
771+
772+
failure_message = "<failed to serialize, use init(debug=True) to see error logs>"
773+
774+
frame = sys._getframe()
775+
with mock.patch("sentry_sdk.serializer.serialize", return_value=failure_message):
776+
result = serialize_frame(frame)
777+
778+
assert result["vars"] == failure_message
779+
780+
532781
@pytest.mark.parametrize(
533782
"item,regex_list,expected_result",
534783
[

0 commit comments

Comments
 (0)