diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index cba1319b6f..0aa17ffe38 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -8,6 +8,7 @@ from sentry_sdk.utils import ( AnnotatedValue, + bounded_repr, capture_internal_exception, disable_capture_event, format_timestamp, @@ -47,6 +48,14 @@ MAX_DATABAG_BREADTH = 10 CYCLE_MARKER = "" +# Upper bound on the length of a single object's repr when max_value_length is +# not set (string truncation is disabled by default, see #6290). This does not +# shorten string values; it only stops the repr of a large container/dataclass +# graph from being fully materialized. A value repr larger than the maximum +# event size would be trimmed away later anyway, so building it is pure waste +# and, worse, can block the event loop (#6649). +MAX_REPR_LENGTH = 100_000 + global_repr_processors: "List[ReprProcessor]" = [] @@ -123,10 +132,19 @@ def _safe_repr_wrapper(self, value: "Any") -> str: repr_value = None if self.custom_repr is not None: repr_value = self.custom_repr(value) - return repr_value or safe_repr(value) + return repr_value or bounded_repr(value, self._max_repr_length()) except Exception: return safe_repr(value) + def _max_repr_length(self) -> int: + # Bound how much of an object's repr we build. When the user set + # max_value_length, the result is truncated to it anyway, so there is + # no point building more than that. Otherwise fall back to a generous + # default so that only pathologically large graphs are cut (#6649). + if self.max_value_length is not None: + return self.max_value_length + return MAX_REPR_LENGTH + def _annotate(self, **meta: "Any") -> None: while len(self.meta_stack) <= len(self.path): try: diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index a6ece4faf1..3fd5a87738 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1,5 +1,6 @@ import base64 import copy +import dataclasses import json import linecache import logging @@ -546,6 +547,115 @@ def safe_repr(value: "Any") -> str: return "" +# Maximum recursion depth when building a length-bounded repr of an object +# graph. Anything deeper is rendered as "...". This mirrors the databag depth +# limit and guards against pathologically deep (or self-referential) objects. +_MAX_BOUNDED_REPR_DEPTH = 100 + + +class _BoundedReprLimit(Exception): + """Raised internally by bounded_repr() once the length budget is spent.""" + + +def bounded_repr(value: "Any", max_length: "Optional[int]") -> str: + """``repr(value)`` that stops once the output would exceed ``max_length``. + + ``repr()`` of a container or a dataclass walks the whole object graph and + builds the entire string up front. When that string is then truncated by + the serializer, everything past the limit was built for nothing. For a + large graph the cost is significant: FastAPI's ``_IncludedRouter`` is a + dataclass whose auto-generated ``__repr__`` recurses through the full + router tree, so a single frame local can turn into a multi-megabyte repr + that blocks the event loop for hundreds of milliseconds before truncation + (getsentry/sentry-python#6649). + + This renders dataclass fields and the standard container types itself and + stops as soon as the accumulated output reaches ``max_length``, returning a + prefix of ``repr(value)`` followed by ``"..."``. When the full repr fits + within ``max_length`` the result is byte-for-byte identical to + ``repr(value)``. Leaf values (strings, numbers, arbitrary objects with a + custom ``__repr__``, ...) are always rendered in full, so string values are + never shortened here; only over-large container/dataclass graphs are cut. + + Objects that are neither dataclasses nor standard containers fall back to + ``safe_repr()``. + """ + if max_length is None: + return safe_repr(value) + + chunks: "List[str]" = [] + total = 0 + + def emit(text: str) -> None: + nonlocal total + chunks.append(text) + total += len(text) + + def render(obj: "Any", depth: int) -> None: + if depth > _MAX_BOUNDED_REPR_DEPTH: + emit("...") + return + + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + items = ( + (field.name + "=", getattr(obj, field.name)) + for field in dataclasses.fields(obj) + if field.repr + ) + _render_items(type(obj).__qualname__ + "(", ")", items, depth) + return + + obj_type = type(obj) + if obj_type is dict: + _render_items( + "{", "}", ((safe_repr(k) + ": ", v) for k, v in obj.items()), depth + ) + elif obj_type is list: + _render_items("[", "]", (("", v) for v in obj), depth) + elif obj_type is tuple: + if len(obj) == 1: + emit("(") + render(obj[0], depth + 1) + emit(",)") + else: + _render_items("(", ")", (("", v) for v in obj), depth) + elif obj_type is set: + if obj: + _render_items("{", "}", (("", v) for v in obj), depth) + else: + emit("set()") + elif obj_type is frozenset: + if obj: + _render_items("frozenset({", "})", (("", v) for v in obj), depth) + else: + emit("frozenset()") + else: + emit(safe_repr(obj)) + + def _render_items(opening: str, closing: str, items: "Any", depth: int) -> None: + emit(opening) + first = True + for prefix, child in items: + if total >= max_length: + # The graph is bigger than the budget. Stop here and let the + # caller mark the value as truncated. What we have emitted so + # far is a literal prefix of repr(value). + raise _BoundedReprLimit + if not first: + emit(", ") + first = False + emit(prefix) + render(child, depth + 1) + emit(closing) + + try: + render(value, 0) + except _BoundedReprLimit: + chunks.append("...") + + return "".join(chunks) + + def filename_for_module( module: "Optional[str]", abs_path: "Optional[str]" ) -> "Optional[str]": diff --git a/tests/test_serializer.py b/tests/test_serializer.py index f1483aba8d..90f3ba11b4 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -1,9 +1,15 @@ import re from array import array +from dataclasses import dataclass import pytest -from sentry_sdk.serializer import MAX_DATABAG_BREADTH, MAX_DATABAG_DEPTH, serialize +from sentry_sdk.serializer import ( + MAX_DATABAG_BREADTH, + MAX_DATABAG_DEPTH, + MAX_REPR_LENGTH, + serialize, +) try: import hypothesis.strategies as st @@ -219,3 +225,65 @@ def __iter__(self): assert result["custom"].startswith( ".Custom object at" ) + + +def test_small_object_repr_is_unchanged(): + # A normal object/dataclass local is still serialized to its exact repr(). + @dataclass + class Point: + x: int + y: str + + point = Point(1, "hi") + result = serialize({"point": point}, is_vars=True)["point"] + assert result == repr(point) + assert "Point(x=1, y='hi')" in result + + +def test_large_object_repr_is_not_fully_materialized(): + # Regression test for #6649: serializing a local whose repr walks a large + # object graph must not build the whole repr and then throw most of it + # away. FastAPI's _IncludedRouter is the real-world trigger; here we use a + # dataclass with an oversized field followed by a sentinel whose __repr__ + # records whether it was reached. + reached = [] + + class Tail: + def __repr__(self): + reached.append(True) + return "" + + @dataclass + class Big: + head: list + tail: object + + big = Big(head=list(range(200000)), tail=Tail()) + + result = serialize({"big": big}, is_vars=True)["big"] + + # We stopped before reaching tail instead of rendering the full graph, so + # tail was never repr'd. Reverting the fix walks the whole graph and trips + # this. + assert reached == [] + # What we kept is a real, truncation-marked prefix of the object's repr. + assert "Big(head=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10," in result + assert result.endswith("...") + assert MAX_REPR_LENGTH <= len(result) < MAX_REPR_LENGTH + 100 + + +def test_large_object_repr_respects_max_value_length(): + # With max_value_length set, the bounded repr yields exactly what the old + # build-the-whole-repr-then-truncate path produced: capped to the limit + # and a genuine prefix of repr(). + @dataclass + class Big: + data: list + + big = Big(data=list(range(100000))) + + result = serialize({"big": big}, is_vars=True, max_value_length=1024)["big"] + + assert len(result) == 1024 + assert result.endswith("...") + assert repr(big).startswith(result[:-3])