From 73f8f545b9ebbc47ff54e31b55d99aa5bdd5cf5c Mon Sep 17 00:00:00 2001 From: zkasuran Date: Wed, 12 Aug 2026 11:05:37 +0530 Subject: [PATCH] fix(serializer): Avoid building full repr of large objects (#6649) When capturing frame locals, the serializer builds repr() of any non-container object in full and only truncates the resulting string afterwards. repr() of a large container or dataclass walks the whole object graph, so the string is built entirely and then mostly thrown away. On FastAPI >= 0.137 every nested routing frame holds an _IncludedRouter, a dataclass whose auto-generated __repr__ recurses through the full router tree. Serializing one frame local turned into a multi-megabyte repr. The same object appears in each nested frame, so logging a single exception blocked the event loop for hundreds of milliseconds to seconds and could trip gunicorn UvicornWorker timeouts. Add bounded_repr(), which renders dataclass fields and the standard container types itself and stops once the output reaches the limit, returning a prefix of repr() marked with "...". When the full repr fits the result is identical to repr(). Leaf values including strings are rendered in full, so string values are never shortened. The serializer now uses it, capped at max_value_length when set and otherwise at a generous default so only pathologically large graphs are cut. --- sentry_sdk/serializer.py | 20 ++++++- sentry_sdk/utils.py | 110 +++++++++++++++++++++++++++++++++++++++ tests/test_serializer.py | 70 ++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 2 deletions(-) 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])