This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
This entry is a real, tested bug fix in the Sentry Python SDK for issue #6649. On FastAPI 0.137 and newer, logging a single exception could freeze an async service for seconds because Sentry was building a giant text representation of the framework's internal router objects while capturing stack-frame locals. The fix makes that capture bounded and fast without changing what data ends up in Sentry for normal cases.
Bug Fix or Performance Improvement
When Sentry captures an exception with include_local_variables=True (the default), it serializes every stack frame's local variables. For any value that is not a plain container it falls back to Python's repr(), builds the whole string, then truncates it. That is fine for small objects. It is not fine when the object's repr() walks a large graph.
FastAPI 0.137 stopped flattening routes on include_router(). Each nested routing frame now holds a fastapi.routing._IncludedRouter, a dataclass whose auto-generated __repr__ recurses through the entire router tree. The same object shows up as self, route and included_router in every nested frame, so Sentry paid for that whole-app repr() over and over. Measured locally at 800 routes, one _IncludedRouter repr is 1,782,273 characters. In production at thousands of routes the reporter saw about 20 seconds per logged error, long enough to trip the gunicorn UvicornWorker timeout and get the worker killed. On an async app this blocks the event loop the whole time.
The cost is building the string, not storing it, so simply capping the output length does not help. Neither does reprlib (it still calls the object's full repr() first). The fix has to avoid materializing the graph in the first place.
Here is the wall time of one logger.error(..., exc_info=exc) with locals on, by route count, before and after:
| routes | before | after |
|---|---|---|
| 200 | 0.180s | 0.121s |
| 400 | 0.385s | 0.120s |
| 800 | 0.768s | 0.121s |
| 1600 | 1.553s | 0.131s |
Before, the time grows linearly with the size of the app. After, it is flat, so it no longer scales with the router graph. The per-frame local serialization at 800 routes drops from 0.303s to 0.008s, about 38x.
Code
The fix, as a PR: getsentry/sentry-python#7176, which a maintainer invited on #6649. Branch fix/logging-locals-serialization-eventloop-stall, commit 73f8f54.
The core of the fix is a new bounded_repr() in sentry_sdk/utils.py. It renders dataclass fields and the standard container types itself and stops the moment the output reaches the budget, so it never calls the object's own recursive __repr__ on a large graph.
def bounded_repr(value, max_length):
"""repr(value) that stops once the output would exceed max_length.
Renders dataclass fields and standard containers itself and stops as
soon as the output reaches max_length, returning a prefix of repr(value)
followed by "...". When the full repr fits, the result is byte-for-byte
identical to repr(value). Leaf values (strings, numbers, objects with a
custom __repr__) are always rendered in full, so string values are never
shortened; only over-large container/dataclass graphs are cut.
"""
if max_length is None:
return safe_repr(value)
chunks = []
total = 0
def emit(text):
nonlocal total
chunks.append(text)
total += len(text)
def render(obj, depth):
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)
# tuple / set / frozenset handled the same way ...
else:
emit(safe_repr(obj))
def _render_items(opening, closing, items, depth):
emit(opening)
first = True
for prefix, child in items:
if total >= max_length:
raise _BoundedReprLimit # what we emitted is a real prefix of repr(value)
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)
The serializer then uses it from the same place it used safe_repr, so custom_repr still runs first, capped at max_value_length when set and otherwise at a generous default:
def _safe_repr_wrapper(self, value):
try:
repr_value = None
if self.custom_repr is not None:
repr_value = self.custom_repr(value)
return repr_value or bounded_repr(value, self._max_repr_length())
except Exception:
return safe_repr(value)
My Improvements
The change is small and focused: three files, +198 / -2. It keeps the captured data the same for normal cases and only bounds the pathological one.
What it preserves: when a value's full repr() fits within the budget the result is byte-for-byte identical to repr(), verified over 3000+ randomized nested structures with zero mismatches. Leaf strings are always rendered in full, so the "keep long strings" behavior from a recent SDK change stays intact. There is no FastAPI-specific code, so the maintainer's concern about a special case clashing with custom_repr does not apply.
Three tests were added to tests/test_serializer.py: one that a small object still serializes to its exact repr(), one that a large object is not fully materialized (a sentinel placed after an oversized field is never reached, so reverting the fix trips it), one that with max_value_length set the output matches the old build-then-truncate result. Verification, all green: 230 passed and 2 skipped across the serializer, utils and logging suites, ruff check and ruff format --check clean on the changed files, mypy sentry_sdk shows no new errors versus the base.
Best Use of Sentry
The best use of Sentry here is making Sentry itself better for every async Python service that uses it. Error capture should never be the thing that takes a service down. On modern FastAPI this bug could turn a normal 500 into a killed worker. This fix keeps Sentry's rich local-variable capture (which is genuinely useful for debugging) while making its cost bounded and predictable, so teams can leave include_local_variables=True on in production without risking event-loop stalls. It also hardens the serializer against any object whose repr() walks a large graph, not just FastAPI's, which is exactly the general limit the maintainers said they wanted.
AI disclosure
AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the new and existing serializer, utils and logging tests pass, ruff check and ruff format --check are clean on the changed files, mypy sentry_sdk shows no new errors versus the base.
Top comments (0)