Repository: getsentry/sentry-python

Issue:
Expose custom safe_repr config
@sl0thentr0py Thanks, yes, I think we prefer option 2 - the option for a custom `repr` function to be tried in `safe_repr` (my current understanding of how everything works is that this will both solve our needs and reduce the likelihood of this bug to creeping back in).

Thanks for giving us the choice 🙂

_Originally posted by @philipstarkey in https://github.com/getsentry/sentry-python/issues/3409#issuecomment-2277748660_
            

Discussion:
codecov[bot]: **Test Failures Detected**: Due to failing tests, we cannot provide coverage reports at this time.

### :x: Failed Test Results: 
Completed 1770 tests with **`5 failed`**, 1574 passed and 191 skipped.
<details><summary>View the full list of failed tests</summary>

## py3.11-common
- **Class name:** tests.test_exceptiongroup<br>**Test name:** test_exception_chain_cause<br><br>
  <pre>tests/test_exceptiongroup.py:206: in test_exception_chain_cause<br>    (event, _) = event_from_exception(<br>sentry_sdk/utils.py:1050: in event_from_exception<br>    &quot;values&quot;: exceptions_from_error_tuple(<br>sentry_sdk/utils.py:918: in exceptions_from_error_tuple<br>    single_exception_from_error_tuple(<br>sentry_sdk/utils.py:734: in single_exception_from_error_tuple<br>    custom_repr = client_options[&quot;custom_repr&quot;]<br>E   KeyError: &apos;custom_repr&apos;</pre>
- **Class name:** tests.test_exceptiongroup<br>**Test name:** test_exception_chain_context<br><br>
  <pre>tests/test_exceptiongroup.py:246: in test_exception_chain_context<br>    (event, _) = event_from_exception(<br>sentry_sdk/utils.py:1050: in event_from_exception<br>    &quot;values&quot;: exceptions_from_error_tuple(<br>sentry_sdk/utils.py:918: in exceptions_from_error_tuple<br>    single_exception_from_error_tuple(<br>sentry_sdk/utils.py:734: in single_exception_from_error_tuple<br>    custom_repr = client_options[&quot;custom_repr&quot;]<br>E   KeyError: &apos;custom_repr&apos;</pre>
- **Class name:** tests.test_exceptiongroup<br>**Test name:** test_exceptiongroup<br><br>
  <pre>tests/test_exceptiongroup.py:45: in test_exceptiongroup<br>    (event, _) = event_from_exception(<br>sentry_sdk/utils.py:1050: in event_from_exception<br>    &quot;values&quot;: exceptions_from_error_tuple(<br>sentry_sdk/utils.py:904: in exceptions_from_error_tuple<br>    (_, exceptions) = exceptions_from_error(<br>sentry_sdk/utils.py:814: in exceptions_from_error<br>    parent = single_exception_from_error_tuple(<br>sentry_sdk/utils.py:734: in single_exception_from_error_tuple<br>    custom_repr = client_options[&quot;custom_repr&quot;]<br>E   KeyError: &apos;custom_repr&apos;</pre>
- **Class name:** tests.test_exceptiongroup<br>**Test name:** test_exceptiongroup_simple<br><br>
  <pre>tests/test_exceptiongroup.py:161: in test_exceptiongroup_simple<br>    (event, _) = event_from_exception(<br>sentry_sdk/utils.py:1050: in event_from_exception<br>    &quot;values&quot;: exceptions_from_error_tuple(<br>sentry_sdk/utils.py:904: in exceptions_from_error_tuple<br>    (_, exceptions) = exceptions_from_error(<br>sentry_sdk/utils.py:814: in exceptions_from_error<br>    parent = single_exception_from_error_tuple(<br>sentry_sdk/utils.py:734: in single_exception_from_error_tuple<br>    custom_repr = client_options[&quot;custom_repr&quot;]<br>E   KeyError: &apos;custom_repr&apos;</pre>
- **Class name:** tests.test_exceptiongroup<br>**Test name:** test_simple_exception<br><br>
  <pre>tests/test_exceptiongroup.py:285: in test_simple_exception<br>    (event, _) = event_from_exception(<br>sentry_sdk/utils.py:1050: in event_from_exception<br>    &quot;values&quot;: exceptions_from_error_tuple(<br>sentry_sdk/utils.py:918: in exceptions_from_error_tuple<br>    single_exception_from_error_tuple(<br>sentry_sdk/utils.py:734: in single_exception_from_error_tuple<br>    custom_repr = client_options[&quot;custom_repr&quot;]<br>E   KeyError: &apos;custom_repr&apos;</pre>
</details>
sl0thentr0py: docs here
https://github.com/getsentry/sentry-docs/pull/11059


Patch:
diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py
index d22dd1c0..8a3cd715 100644
--- a/sentry_sdk/client.py
+++ b/sentry_sdk/client.py
@@ -531,6 +531,7 @@ class _Client(BaseClient):
                     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 b50a2843..ca805d3a 100644
--- a/sentry_sdk/consts.py
+++ b/sentry_sdk/consts.py
@@ -539,6 +539,7 @@ class ClientConstructor:
         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 010c1a96..7171885f 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(event, **kwargs):
             _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(event, **kwargs):
             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(event, **kwargs):
             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(event, **kwargs):
             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 8b718a1f..d731fa22 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)
     ]


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index f6c2cec0..d56bab0b 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -944,6 +944,39 @@ def test_dict_changed_during_iteration(sentry_init, capture_events):
     assert frame["vars"]["environ"] == {"a": "<This is me>"}
 
 
+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 a3ead112..2f158097 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)