Skip to content

Commit 62af515

Browse files
authored
fix(serializer): avoid creating reference cycles on every call (#6563)
Nested serializer functions are moved to methods of a new `_Serializer` class, and `serialize()` creates an instance of the class. Prevents leaving reference cycles behind that only the cyclic GC can free.
1 parent a9b4f1f commit 62af515

2 files changed

Lines changed: 142 additions & 82 deletions

File tree

sentry_sdk/serializer.py

Lines changed: 118 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -89,74 +89,73 @@ def __exit__(
8989
self._ids.pop(id(self._objs.pop()), None)
9090

9191

92-
def serialize(event: "Dict[str, Any]", **kwargs: "Any") -> "Dict[str, Any]":
93-
"""
94-
A very smart serializer that takes a dict and emits a json-friendly dict.
95-
Currently used for serializing the final Event and also prematurely while fetching the stack
96-
local variables for each frame in a stacktrace.
97-
98-
It works internally with 'databags' which are arbitrary data structures like Mapping, Sequence and Set.
99-
The algorithm itself is a recursive graph walk down the data structures it encounters.
100-
101-
It has the following responsibilities:
102-
* Trimming databags and keeping them within MAX_DATABAG_BREADTH and MAX_DATABAG_DEPTH.
103-
* Calling safe_repr() on objects appropriately to keep them informative and readable in the final payload.
104-
* Annotating the payload with the _meta field whenever trimming happens.
105-
106-
:param max_request_body_size: If set to "always", will never trim request bodies.
107-
:param max_value_length: The max length to strip strings to, or None to disable string truncation. Defaults to None.
108-
: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.
109-
: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.
110-
111-
"""
112-
memo = Memo()
113-
path: "List[Segment]" = []
114-
meta_stack: "List[Dict[str, Any]]" = []
115-
116-
keep_request_bodies: bool = kwargs.pop("max_request_body_size", None) == "always"
117-
max_value_length: "Optional[int]" = kwargs.pop("max_value_length", None)
118-
is_vars = kwargs.pop("is_vars", False)
119-
custom_repr: "Callable[..., Optional[str]]" = kwargs.pop("custom_repr", None)
120-
121-
def _safe_repr_wrapper(value: "Any") -> str:
92+
class _Serializer:
93+
"""Holds the state of a single serialize() call."""
94+
95+
__slots__ = (
96+
"memo",
97+
"path",
98+
"meta_stack",
99+
"keep_request_bodies",
100+
"max_value_length",
101+
"is_vars",
102+
"custom_repr",
103+
)
104+
105+
def __init__(
106+
self,
107+
keep_request_bodies: bool,
108+
max_value_length: "Optional[int]",
109+
is_vars: bool,
110+
custom_repr: "Optional[Callable[..., Optional[str]]]",
111+
) -> None:
112+
self.memo = Memo()
113+
self.path: "List[Segment]" = []
114+
self.meta_stack: "List[Dict[str, Any]]" = []
115+
self.keep_request_bodies = keep_request_bodies
116+
self.max_value_length = max_value_length
117+
self.is_vars = is_vars
118+
self.custom_repr = custom_repr
119+
120+
def _safe_repr_wrapper(self, value: "Any") -> str:
122121
try:
123122
repr_value = None
124-
if custom_repr is not None:
125-
repr_value = custom_repr(value)
123+
if self.custom_repr is not None:
124+
repr_value = self.custom_repr(value)
126125
return repr_value or safe_repr(value)
127126
except Exception:
128127
return safe_repr(value)
129128

130-
def _annotate(**meta: "Any") -> None:
131-
while len(meta_stack) <= len(path):
129+
def _annotate(self, **meta: "Any") -> None:
130+
while len(self.meta_stack) <= len(self.path):
132131
try:
133-
segment = path[len(meta_stack) - 1]
134-
node = meta_stack[-1].setdefault(str(segment), {})
132+
segment = self.path[len(self.meta_stack) - 1]
133+
node = self.meta_stack[-1].setdefault(str(segment), {})
135134
except IndexError:
136135
node = {}
137136

138-
meta_stack.append(node)
137+
self.meta_stack.append(node)
139138

140-
meta_stack[-1].setdefault("", {}).update(meta)
139+
self.meta_stack[-1].setdefault("", {}).update(meta)
141140

142-
def _is_databag() -> "Optional[bool]":
141+
def _is_databag(self) -> "Optional[bool]":
143142
"""
144143
A databag is any value that we need to trim.
145144
True for stuff like vars, request bodies, breadcrumbs and extra.
146145
147146
:returns: `True` for "yes", `False` for :"no", `None` for "maybe soon".
148147
"""
149148
try:
150-
if is_vars:
149+
if self.is_vars:
151150
return True
152151

153-
is_request_body = _is_request_body()
152+
is_request_body = self._is_request_body()
154153
if is_request_body in (True, None):
155154
return is_request_body
156155

157-
p0 = path[0]
158-
if p0 == "breadcrumbs" and path[1] == "values":
159-
path[2]
156+
p0 = self.path[0]
157+
if p0 == "breadcrumbs" and self.path[1] == "values":
158+
self.path[2]
160159
return True
161160

162161
if p0 == "extra":
@@ -167,25 +166,26 @@ def _is_databag() -> "Optional[bool]":
167166

168167
return False
169168

170-
def _is_span_attribute() -> "Optional[bool]":
169+
def _is_span_attribute(self) -> "Optional[bool]":
171170
try:
172-
if path[0] == "spans" and path[2] == "data":
171+
if self.path[0] == "spans" and self.path[2] == "data":
173172
return True
174173
except IndexError:
175174
return None
176175

177176
return False
178177

179-
def _is_request_body() -> "Optional[bool]":
178+
def _is_request_body(self) -> "Optional[bool]":
180179
try:
181-
if path[0] == "request" and path[1] == "data":
180+
if self.path[0] == "request" and self.path[1] == "data":
182181
return True
183182
except IndexError:
184183
return None
185184

186185
return False
187186

188187
def _serialize_node(
188+
self,
189189
obj: "Any",
190190
is_databag: "Optional[bool]" = None,
191191
is_request_body: "Optional[bool]" = None,
@@ -195,14 +195,14 @@ def _serialize_node(
195195
remaining_depth: "Optional[Union[int, float]]" = None,
196196
) -> "Any":
197197
if segment is not None:
198-
path.append(segment)
198+
self.path.append(segment)
199199

200200
try:
201-
with memo.memoize(obj) as result:
201+
with self.memo.memoize(obj) as result:
202202
if result:
203203
return CYCLE_MARKER
204204

205-
return _serialize_node_impl(
205+
return self._serialize_node_impl(
206206
obj,
207207
is_databag=is_databag,
208208
is_request_body=is_request_body,
@@ -219,16 +219,17 @@ def _serialize_node(
219219
return None
220220
finally:
221221
if segment is not None:
222-
path.pop()
223-
del meta_stack[len(path) + 1 :]
222+
self.path.pop()
223+
del self.meta_stack[len(self.path) + 1 :]
224224

225-
def _flatten_annotated(obj: "Any") -> "Any":
225+
def _flatten_annotated(self, obj: "Any") -> "Any":
226226
if isinstance(obj, AnnotatedValue):
227-
_annotate(**obj.metadata)
227+
self._annotate(**obj.metadata)
228228
obj = obj.value
229229
return obj
230230

231231
def _serialize_node_impl(
232+
self,
232233
obj: "Any",
233234
is_databag: "Optional[bool]",
234235
is_request_body: "Optional[bool]",
@@ -239,16 +240,16 @@ def _serialize_node_impl(
239240
if isinstance(obj, AnnotatedValue):
240241
should_repr_strings = False
241242
if should_repr_strings is None:
242-
should_repr_strings = is_vars
243+
should_repr_strings = self.is_vars
243244

244245
if is_databag is None:
245-
is_databag = _is_databag()
246+
is_databag = self._is_databag()
246247

247248
if is_request_body is None:
248-
is_request_body = _is_request_body()
249+
is_request_body = self._is_request_body()
249250

250251
if is_databag:
251-
if is_request_body and keep_request_bodies:
252+
if is_request_body and self.keep_request_bodies:
252253
remaining_depth = float("inf")
253254
remaining_breadth = float("inf")
254255
else:
@@ -257,31 +258,33 @@ def _serialize_node_impl(
257258
if remaining_breadth is None:
258259
remaining_breadth = MAX_DATABAG_BREADTH
259260

260-
obj = _flatten_annotated(obj)
261+
obj = self._flatten_annotated(obj)
261262

262263
if remaining_depth is not None and remaining_depth <= 0:
263-
_annotate(rem=[["!limit", "x"]])
264+
self._annotate(rem=[["!limit", "x"]])
264265
if is_databag:
265-
return _flatten_annotated(
266-
strip_string(_safe_repr_wrapper(obj), max_length=max_value_length)
266+
return self._flatten_annotated(
267+
strip_string(
268+
self._safe_repr_wrapper(obj), max_length=self.max_value_length
269+
)
267270
)
268271
return None
269272

270-
is_span_attribute = _is_span_attribute()
273+
is_span_attribute = self._is_span_attribute()
271274
if (is_databag or is_span_attribute) and global_repr_processors:
272-
hints = {"memo": memo, "remaining_depth": remaining_depth}
275+
hints = {"memo": self.memo, "remaining_depth": remaining_depth}
273276
for processor in global_repr_processors:
274277
result = processor(obj, hints)
275278
if result is not NotImplemented:
276-
return _flatten_annotated(result)
279+
return self._flatten_annotated(result)
277280

278281
sentry_repr = getattr(type(obj), "__sentry_repr__", None)
279282

280283
if obj is None or isinstance(obj, (bool, int, float)):
281284
if should_repr_strings or (
282285
isinstance(obj, float) and (math.isinf(obj) or math.isnan(obj))
283286
):
284-
return _safe_repr_wrapper(obj)
287+
return self._safe_repr_wrapper(obj)
285288
else:
286289
return obj
287290

@@ -292,7 +295,7 @@ def _serialize_node_impl(
292295
return (
293296
str(format_timestamp(obj))
294297
if not should_repr_strings
295-
else _safe_repr_wrapper(obj)
298+
else self._safe_repr_wrapper(obj)
296299
)
297300

298301
elif isinstance(obj, Mapping):
@@ -305,11 +308,11 @@ def _serialize_node_impl(
305308

306309
for k, v in obj.items():
307310
if remaining_breadth is not None and i >= remaining_breadth:
308-
_annotate(len=len(obj))
311+
self._annotate(len=len(obj))
309312
break
310313

311314
str_k = str(k)
312-
v = _serialize_node(
315+
v = self._serialize_node(
313316
v,
314317
segment=str_k,
315318
should_repr_strings=should_repr_strings,
@@ -332,11 +335,11 @@ def _serialize_node_impl(
332335

333336
for i, v in enumerate(obj): # type: ignore[arg-type]
334337
if remaining_breadth is not None and i >= remaining_breadth:
335-
_annotate(len=len(obj)) # type: ignore[arg-type]
338+
self._annotate(len=len(obj)) # type: ignore[arg-type]
336339
break
337340

338341
rv_list.append(
339-
_serialize_node(
342+
self._serialize_node(
340343
v,
341344
segment=i,
342345
should_repr_strings=should_repr_strings,
@@ -352,30 +355,63 @@ def _serialize_node_impl(
352355
return rv_list
353356

354357
if should_repr_strings:
355-
obj = _safe_repr_wrapper(obj)
358+
obj = self._safe_repr_wrapper(obj)
356359
else:
357360
if isinstance(obj, bytes) or isinstance(obj, bytearray):
358361
obj = obj.decode("utf-8", "replace")
359362

360363
if not isinstance(obj, str):
361-
obj = _safe_repr_wrapper(obj)
364+
obj = self._safe_repr_wrapper(obj)
362365

363366
is_span_description = (
364-
len(path) == 3 and path[0] == "spans" and path[-1] == "description"
367+
len(self.path) == 3
368+
and self.path[0] == "spans"
369+
and self.path[-1] == "description"
365370
)
366371
if is_span_description:
367372
return obj
368373

369-
return _flatten_annotated(strip_string(obj, max_length=max_value_length))
374+
return self._flatten_annotated(
375+
strip_string(obj, max_length=self.max_value_length)
376+
)
377+
378+
379+
def serialize(event: "Dict[str, Any]", **kwargs: "Any") -> "Dict[str, Any]":
380+
"""
381+
A very smart serializer that takes a dict and emits a json-friendly dict.
382+
Currently used for serializing the final Event and also prematurely while fetching the stack
383+
local variables for each frame in a stacktrace.
384+
385+
It works internally with 'databags' which are arbitrary data structures like Mapping, Sequence and Set.
386+
The algorithm itself is a recursive graph walk down the data structures it encounters.
387+
388+
It has the following responsibilities:
389+
* Trimming databags and keeping them within MAX_DATABAG_BREADTH and MAX_DATABAG_DEPTH.
390+
* Calling safe_repr() on objects appropriately to keep them informative and readable in the final payload.
391+
* Annotating the payload with the _meta field whenever trimming happens.
392+
393+
:param max_request_body_size: If set to "always", will never trim request bodies.
394+
:param max_value_length: The max length to strip strings to, or None to disable string truncation. Defaults to None.
395+
: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.
396+
: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.
397+
398+
"""
399+
serializer = _Serializer(
400+
keep_request_bodies=kwargs.pop("max_request_body_size", None) == "always",
401+
max_value_length=kwargs.pop("max_value_length", None),
402+
is_vars=kwargs.pop("is_vars", False),
403+
custom_repr=kwargs.pop("custom_repr", None),
404+
)
370405

371-
#
372-
# Start of serialize() function
373-
#
374406
disable_capture_event.set(True)
375407
try:
376-
serialized_event = _serialize_node(event, **kwargs)
377-
if not is_vars and meta_stack and isinstance(serialized_event, dict):
378-
serialized_event["_meta"] = meta_stack[0]
408+
serialized_event = serializer._serialize_node(event, **kwargs)
409+
if (
410+
not serializer.is_vars
411+
and serializer.meta_stack
412+
and isinstance(serialized_event, dict)
413+
):
414+
serialized_event["_meta"] = serializer.meta_stack[0]
379415

380416
return serialized_event
381417
finally:

tests/test_serializer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import gc
12
import re
23
from array import array
34

@@ -183,6 +184,29 @@ def test_max_value_length(body_normalizer):
183184
assert len(result["key"]) == max_value_length
184185

185186

187+
def test_serialize_does_not_leave_cyclic_garbage():
188+
# Applications running with the GC disabled rely on serialize() being
189+
# freed by reference counting alone, so it must not create reference
190+
# cycles.
191+
gc_was_enabled = gc.isenabled()
192+
old_debug_flags = gc.get_debug()
193+
gc.collect()
194+
gc.disable()
195+
try:
196+
serialize({"extra": {"foo": [{"bar": i} for i in range(20)]}})
197+
serialize({"foo": "bar"}, is_vars=True)
198+
199+
gc.set_debug(gc.DEBUG_SAVEALL)
200+
gc.collect()
201+
assert gc.garbage == []
202+
finally:
203+
gc.set_debug(old_debug_flags)
204+
gc.garbage.clear()
205+
gc.collect()
206+
if gc_was_enabled:
207+
gc.enable()
208+
209+
186210
def test_serialize_local_vars():
187211
# This was added to make sure we don't try to iterate over instances of
188212
# custom classes with an __iter__ method due to potential side effects

0 commit comments

Comments
 (0)