Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion sentry_sdk/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from sentry_sdk.utils import (
AnnotatedValue,
bounded_repr,
capture_internal_exception,
disable_capture_event,
format_timestamp,
Expand Down Expand Up @@ -47,6 +48,14 @@
MAX_DATABAG_BREADTH = 10
CYCLE_MARKER = "<cyclic>"

# 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]" = []

Expand Down Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import copy
import dataclasses

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dataclasses import breaks Python 3.6

High Severity

Unconditional import dataclasses makes sentry_sdk.utils fail to import on Python 3.6. That module is not in the standard library there, and dataclasses is only a test dependency, not an install_requires entry, so production 3.6 installs will fail on import sentry_sdk.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 73f8f54. Configure here.

import json
import linecache
import logging
Expand Down Expand Up @@ -546,6 +547,115 @@ def safe_repr(value: "Any") -> str:
return "<broken repr>"


# 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't do this in a minor version.
The repr() implementation for built-in types is a Python implementation detail, and enforcing our own logic is a disruptive change.

"""``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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dataclass repr overrides ignored

High Severity

bounded_repr treats every dataclass instance as if it used the default field repr. Class-level repr=False and custom __repr__ methods are skipped, so values that were intentionally omitted or redacted can appear in serialized frame locals.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 73f8f54. Configure here.


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]":
Expand Down
70 changes: 69 additions & 1 deletion tests/test_serializer.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -219,3 +225,65 @@ def __iter__(self):
assert result["custom"].startswith(
"<tests.test_serializer.test_serialize_local_vars.<locals>.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 "<tail>"

@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])