From 48589966945785787a2855533386a2648e9df784 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Mon, 12 Aug 2024 16:32:42 +0200 Subject: [PATCH] Expose custom_repr function that precedes safe_repr invocation in serializer (#3438) closes #3427 --- sentry_sdk/client.py | 1 + sentry_sdk/consts.py | 1 + sentry_sdk/serializer.py | 22 +++++++++++++++++----- sentry_sdk/utils.py | 10 ++++++++-- tests/test_client.py | 33 +++++++++++++++++++++++++++++++++ tests/test_serializer.py | 25 +++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index d22dd1c0a4..8a3cd715f1 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -531,6 +531,7 @@ def _prepare_event( cast("Dict[str, Any]", event), max_request_body_size=self.options.get("max_request_body_size"), max_value_length=self.options.get("max_value_length"), + custom_repr=self.options.get("custom_repr"), ), ) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index b50a2843a6..ca805d3a3e 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -539,6 +539,7 @@ def __init__( spotlight=None, # type: Optional[Union[bool, str]] cert_file=None, # type: Optional[str] key_file=None, # type: Optional[str] + custom_repr=None, # type: Optional[Callable[..., Optional[str]]] ): # type: (...) -> None pass diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index 010c1a963f..7171885f43 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -112,6 +112,7 @@ def serialize(event, **kwargs): :param max_request_body_size: If set to "always", will never trim request bodies. :param max_value_length: The max length to strip strings to, defaults to sentry_sdk.consts.DEFAULT_MAX_VALUE_LENGTH :param is_vars: If we're serializing vars early, we want to repr() things that are JSON-serializable to make their type more apparent. For example, it's useful to see the difference between a unicode-string and a bytestring when viewing a stacktrace. + :param custom_repr: A custom repr function that runs before safe_repr on the object to be serialized. If it returns None or throws internally, we will fallback to safe_repr. """ memo = Memo() @@ -123,6 +124,17 @@ def serialize(event, **kwargs): ) # type: bool max_value_length = kwargs.pop("max_value_length", None) # type: Optional[int] is_vars = kwargs.pop("is_vars", False) + custom_repr = kwargs.pop("custom_repr", None) # type: Callable[..., Optional[str]] + + def _safe_repr_wrapper(value): + # type: (Any) -> str + try: + repr_value = None + if custom_repr is not None: + repr_value = custom_repr(value) + return repr_value or safe_repr(value) + except Exception: + return safe_repr(value) def _annotate(**meta): # type: (**Any) -> None @@ -257,7 +269,7 @@ def _serialize_node_impl( _annotate(rem=[["!limit", "x"]]) if is_databag: return _flatten_annotated( - strip_string(safe_repr(obj), max_length=max_value_length) + strip_string(_safe_repr_wrapper(obj), max_length=max_value_length) ) return None @@ -274,7 +286,7 @@ def _serialize_node_impl( if should_repr_strings or ( isinstance(obj, float) and (math.isinf(obj) or math.isnan(obj)) ): - return safe_repr(obj) + return _safe_repr_wrapper(obj) else: return obj @@ -285,7 +297,7 @@ def _serialize_node_impl( return ( str(format_timestamp(obj)) if not should_repr_strings - else safe_repr(obj) + else _safe_repr_wrapper(obj) ) elif isinstance(obj, Mapping): @@ -345,13 +357,13 @@ def _serialize_node_impl( return rv_list if should_repr_strings: - obj = safe_repr(obj) + obj = _safe_repr_wrapper(obj) else: if isinstance(obj, bytes) or isinstance(obj, bytearray): obj = obj.decode("utf-8", "replace") if not isinstance(obj, str): - obj = safe_repr(obj) + obj = _safe_repr_wrapper(obj) is_span_description = ( len(path) == 3 and path[0] == "spans" and path[-1] == "description" diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 8b718a1f92..d731fa2254 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -585,8 +585,9 @@ def serialize_frame( include_local_variables=True, include_source_context=True, max_value_length=None, + custom_repr=None, ): - # type: (FrameType, Optional[int], bool, bool, Optional[int]) -> Dict[str, Any] + # type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any] f_code = getattr(frame, "f_code", None) if not f_code: abs_path = None @@ -618,7 +619,9 @@ def serialize_frame( if include_local_variables: from sentry_sdk.serializer import serialize - rv["vars"] = serialize(dict(frame.f_locals), is_vars=True) + rv["vars"] = serialize( + dict(frame.f_locals), is_vars=True, custom_repr=custom_repr + ) return rv @@ -723,10 +726,12 @@ def single_exception_from_error_tuple( include_local_variables = True include_source_context = True max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback + custom_repr = None else: include_local_variables = client_options["include_local_variables"] include_source_context = client_options["include_source_context"] max_value_length = client_options["max_value_length"] + custom_repr = client_options.get("custom_repr") frames = [ serialize_frame( @@ -735,6 +740,7 @@ def single_exception_from_error_tuple( include_local_variables=include_local_variables, include_source_context=include_source_context, max_value_length=max_value_length, + custom_repr=custom_repr, ) for tb in iter_stacks(tb) ] diff --git a/tests/test_client.py b/tests/test_client.py index f6c2cec05c..d56bab0b1c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -944,6 +944,39 @@ def __repr__(self): assert frame["vars"]["environ"] == {"a": ""} +def test_custom_repr_on_vars(sentry_init, capture_events): + class Foo: + pass + + class Fail: + pass + + def custom_repr(value): + if isinstance(value, Foo): + return "custom repr" + elif isinstance(value, Fail): + raise ValueError("oops") + else: + return None + + sentry_init(custom_repr=custom_repr) + events = capture_events() + + try: + my_vars = {"foo": Foo(), "fail": Fail(), "normal": 42} + 1 / 0 + except ZeroDivisionError: + capture_exception() + + (event,) = events + (exception,) = event["exception"]["values"] + (frame,) = exception["stacktrace"]["frames"] + my_vars = frame["vars"]["my_vars"] + assert my_vars["foo"] == "custom repr" + assert my_vars["normal"] == "42" + assert "Fail object" in my_vars["fail"] + + @pytest.mark.parametrize( "dsn", [ diff --git a/tests/test_serializer.py b/tests/test_serializer.py index a3ead112a7..2f158097bd 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -114,6 +114,31 @@ def test_custom_mapping_doesnt_mess_with_mock(extra_normalizer): assert len(m.mock_calls) == 0 +def test_custom_repr(extra_normalizer): + class Foo: + pass + + def custom_repr(value): + if isinstance(value, Foo): + return "custom" + else: + return value + + result = extra_normalizer({"foo": Foo(), "string": "abc"}, custom_repr=custom_repr) + assert result == {"foo": "custom", "string": "abc"} + + +def test_custom_repr_graceful_fallback_to_safe_repr(extra_normalizer): + class Foo: + pass + + def custom_repr(value): + raise ValueError("oops") + + result = extra_normalizer({"foo": Foo()}, custom_repr=custom_repr) + assert "Foo object" in result["foo"] + + def test_trim_databag_breadth(body_normalizer): data = { "key{}".format(i): "value{}".format(i) for i in range(MAX_DATABAG_BREADTH + 10)