Skip to content

Commit 4858996

Browse files
authored
Expose custom_repr function that precedes safe_repr invocation in serializer (#3438)
closes #3427
1 parent 275c63e commit 4858996

File tree

6 files changed

+85
-7
lines changed

6 files changed

+85
-7
lines changed

sentry_sdk/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,7 @@ def _prepare_event(
531531
cast("Dict[str, Any]", event),
532532
max_request_body_size=self.options.get("max_request_body_size"),
533533
max_value_length=self.options.get("max_value_length"),
534+
custom_repr=self.options.get("custom_repr"),
534535
),
535536
)
536537

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ def __init__(
539539
spotlight=None, # type: Optional[Union[bool, str]]
540540
cert_file=None, # type: Optional[str]
541541
key_file=None, # type: Optional[str]
542+
custom_repr=None, # type: Optional[Callable[..., Optional[str]]]
542543
):
543544
# type: (...) -> None
544545
pass

sentry_sdk/serializer.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def serialize(event, **kwargs):
112112
:param max_request_body_size: If set to "always", will never trim request bodies.
113113
:param max_value_length: The max length to strip strings to, defaults to sentry_sdk.consts.DEFAULT_MAX_VALUE_LENGTH
114114
: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.
115+
: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.
115116
116117
"""
117118
memo = Memo()
@@ -123,6 +124,17 @@ def serialize(event, **kwargs):
123124
) # type: bool
124125
max_value_length = kwargs.pop("max_value_length", None) # type: Optional[int]
125126
is_vars = kwargs.pop("is_vars", False)
127+
custom_repr = kwargs.pop("custom_repr", None) # type: Callable[..., Optional[str]]
128+
129+
def _safe_repr_wrapper(value):
130+
# type: (Any) -> str
131+
try:
132+
repr_value = None
133+
if custom_repr is not None:
134+
repr_value = custom_repr(value)
135+
return repr_value or safe_repr(value)
136+
except Exception:
137+
return safe_repr(value)
126138

127139
def _annotate(**meta):
128140
# type: (**Any) -> None
@@ -257,7 +269,7 @@ def _serialize_node_impl(
257269
_annotate(rem=[["!limit", "x"]])
258270
if is_databag:
259271
return _flatten_annotated(
260-
strip_string(safe_repr(obj), max_length=max_value_length)
272+
strip_string(_safe_repr_wrapper(obj), max_length=max_value_length)
261273
)
262274
return None
263275

@@ -274,7 +286,7 @@ def _serialize_node_impl(
274286
if should_repr_strings or (
275287
isinstance(obj, float) and (math.isinf(obj) or math.isnan(obj))
276288
):
277-
return safe_repr(obj)
289+
return _safe_repr_wrapper(obj)
278290
else:
279291
return obj
280292

@@ -285,7 +297,7 @@ def _serialize_node_impl(
285297
return (
286298
str(format_timestamp(obj))
287299
if not should_repr_strings
288-
else safe_repr(obj)
300+
else _safe_repr_wrapper(obj)
289301
)
290302

291303
elif isinstance(obj, Mapping):
@@ -345,13 +357,13 @@ def _serialize_node_impl(
345357
return rv_list
346358

347359
if should_repr_strings:
348-
obj = safe_repr(obj)
360+
obj = _safe_repr_wrapper(obj)
349361
else:
350362
if isinstance(obj, bytes) or isinstance(obj, bytearray):
351363
obj = obj.decode("utf-8", "replace")
352364

353365
if not isinstance(obj, str):
354-
obj = safe_repr(obj)
366+
obj = _safe_repr_wrapper(obj)
355367

356368
is_span_description = (
357369
len(path) == 3 and path[0] == "spans" and path[-1] == "description"

sentry_sdk/utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,9 @@ def serialize_frame(
585585
include_local_variables=True,
586586
include_source_context=True,
587587
max_value_length=None,
588+
custom_repr=None,
588589
):
589-
# type: (FrameType, Optional[int], bool, bool, Optional[int]) -> Dict[str, Any]
590+
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
590591
f_code = getattr(frame, "f_code", None)
591592
if not f_code:
592593
abs_path = None
@@ -618,7 +619,9 @@ def serialize_frame(
618619
if include_local_variables:
619620
from sentry_sdk.serializer import serialize
620621

621-
rv["vars"] = serialize(dict(frame.f_locals), is_vars=True)
622+
rv["vars"] = serialize(
623+
dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
624+
)
622625

623626
return rv
624627

@@ -723,10 +726,12 @@ def single_exception_from_error_tuple(
723726
include_local_variables = True
724727
include_source_context = True
725728
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
729+
custom_repr = None
726730
else:
727731
include_local_variables = client_options["include_local_variables"]
728732
include_source_context = client_options["include_source_context"]
729733
max_value_length = client_options["max_value_length"]
734+
custom_repr = client_options.get("custom_repr")
730735

731736
frames = [
732737
serialize_frame(
@@ -735,6 +740,7 @@ def single_exception_from_error_tuple(
735740
include_local_variables=include_local_variables,
736741
include_source_context=include_source_context,
737742
max_value_length=max_value_length,
743+
custom_repr=custom_repr,
738744
)
739745
for tb in iter_stacks(tb)
740746
]

tests/test_client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,39 @@ def __repr__(self):
944944
assert frame["vars"]["environ"] == {"a": "<This is me>"}
945945

946946

947+
def test_custom_repr_on_vars(sentry_init, capture_events):
948+
class Foo:
949+
pass
950+
951+
class Fail:
952+
pass
953+
954+
def custom_repr(value):
955+
if isinstance(value, Foo):
956+
return "custom repr"
957+
elif isinstance(value, Fail):
958+
raise ValueError("oops")
959+
else:
960+
return None
961+
962+
sentry_init(custom_repr=custom_repr)
963+
events = capture_events()
964+
965+
try:
966+
my_vars = {"foo": Foo(), "fail": Fail(), "normal": 42}
967+
1 / 0
968+
except ZeroDivisionError:
969+
capture_exception()
970+
971+
(event,) = events
972+
(exception,) = event["exception"]["values"]
973+
(frame,) = exception["stacktrace"]["frames"]
974+
my_vars = frame["vars"]["my_vars"]
975+
assert my_vars["foo"] == "custom repr"
976+
assert my_vars["normal"] == "42"
977+
assert "Fail object" in my_vars["fail"]
978+
979+
947980
@pytest.mark.parametrize(
948981
"dsn",
949982
[

tests/test_serializer.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,31 @@ def test_custom_mapping_doesnt_mess_with_mock(extra_normalizer):
114114
assert len(m.mock_calls) == 0
115115

116116

117+
def test_custom_repr(extra_normalizer):
118+
class Foo:
119+
pass
120+
121+
def custom_repr(value):
122+
if isinstance(value, Foo):
123+
return "custom"
124+
else:
125+
return value
126+
127+
result = extra_normalizer({"foo": Foo(), "string": "abc"}, custom_repr=custom_repr)
128+
assert result == {"foo": "custom", "string": "abc"}
129+
130+
131+
def test_custom_repr_graceful_fallback_to_safe_repr(extra_normalizer):
132+
class Foo:
133+
pass
134+
135+
def custom_repr(value):
136+
raise ValueError("oops")
137+
138+
result = extra_normalizer({"foo": Foo()}, custom_repr=custom_repr)
139+
assert "Foo object" in result["foo"]
140+
141+
117142
def test_trim_databag_breadth(body_normalizer):
118143
data = {
119144
"key{}".format(i): "value{}".format(i) for i in range(MAX_DATABAG_BREADTH + 10)

0 commit comments

Comments
 (0)