Skip to content

Commit

Permalink
Expose custom_repr function that precedes safe_repr invocation in ser…
Browse files Browse the repository at this point in the history
…ializer (#3438)

closes #3427
  • Loading branch information
sl0thentr0py authored Aug 12, 2024
1 parent 275c63e commit 4858996
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 7 deletions.
1 change: 1 addition & 0 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)

Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions sentry_sdk/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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)
]
Expand Down
33 changes: 33 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,39 @@ def __repr__(self):
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",
[
Expand Down
25 changes: 25 additions & 0 deletions tests/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 4858996

Please sign in to comment.