Skip to content

Memory leak: serializer.serialize() creates reference cycles on every call #6559

@Malkiz223

Description

@Malkiz223

How do you use Sentry?

Self-hosted/on-premise

Version

2.62.0 (Python 3.14, also reproduced on 3.12)

Steps to Reproduce

sentry_sdk.serializer.serialize() defines eight nested functions that reference each other through closure cells, so every call leaves a reference cycle behind (function -> __closure__ cell -> function -> ...). Reference counting cannot free it, only the cyclic GC can.

Our service runs with gc.disable() (latency-sensitive, high event throughput), so this garbage is never collected and RSS grows without bound. serialize() also runs once per stack frame for local variables, so a single captured event pays this cost several times over: one call for the event
itself plus one per frame.

import gc
from collections import Counter

import psutil  # only for RSS reporting
from sentry_sdk.serializer import serialize

SAMPLE_EVENT = {
    'message': 'boom',
    'exception': {'values': [{'type': 'ValueError', 'value': 'boom'}]},
    'extra': {'items': [{'price': index * 1.5, 'name': 'item_%d' % index} for index in range(40)]},
    'breadcrumbs': {'values': [{'message': 'crumb %d' % index} for index in range(20)]},
}

process = psutil.Process()
gc.collect()
gc.disable()

start_rss = process.memory_info().rss
for call_no in range(1, 10_001):
    serialize(dict(SAMPLE_EVENT))
    if call_no % 2_000 == 0:
        rss = process.memory_info().rss
        print(f'after {call_no:6d} serialize() calls: RSS {(rss - start_rss) / 2 ** 20:+7.1f} MiB')

gc.set_debug(gc.DEBUG_SAVEALL)
gc.collect()
functions = [obj for obj in gc.garbage if type(obj).__name__ == 'function']
print(f'cyclic garbage: {len(gc.garbage)} objects')
for qualname, count in Counter(fn.__qualname__ for fn in functions).most_common():
    print(f'  {count:6d}  {qualname}')

Expected Result

Everything serialize() creates internally is freed by reference counting once the call returns: RSS stays flat no matter how many events are serialized, and the run leaves nothing behind for the cyclic garbage collector (gc.garbage stays empty under gc.DEBUG_SAVEALL), so applications running with gc.disable() do not accumulate memory.

Actual Result

Every call leaves ~48 cyclic objects (~6 KiB RSS with this sample event):

after   2000 serialize() calls: RSS   +11.8 MiB
after   4000 serialize() calls: RSS   +24.0 MiB
after   6000 serialize() calls: RSS   +36.2 MiB
after   8000 serialize() calls: RSS   +48.3 MiB
after  10000 serialize() calls: RSS   +60.4 MiB
cyclic garbage: 480000 objects
   10000  serialize.<locals>._safe_repr_wrapper.__annotate__
   10000  serialize.<locals>._safe_repr_wrapper
   10000  serialize.<locals>._annotate.__annotate__
   10000  serialize.<locals>._annotate
   10000  serialize.<locals>._is_databag.__annotate__
   10000  serialize.<locals>._is_databag
   10000  serialize.<locals>._is_span_attribute.__annotate__
   10000  serialize.<locals>._is_span_attribute
   10000  serialize.<locals>._is_request_body.__annotate__
   10000  serialize.<locals>._is_request_body
   10000  serialize.<locals>._serialize_node.__annotate__
   10000  serialize.<locals>._serialize_node
   10000  serialize.<locals>._flatten_annotated.__annotate__
   10000  serialize.<locals>._flatten_annotated
   10000  serialize.<locals>._serialize_node_impl.__annotate__
   10000  serialize.<locals>._serialize_node_impl

On Python 3.12 the same run leaves 40 objects (~4 KiB) per call. On 3.14 it got worse: PEP 649 deferred annotations add a hidden __annotate__ function for every nested function, and those get caught in the same cycle.

With GC enabled the cycles do get collected, but every captured event still produces guaranteed cyclic garbage, adding collector pressure on the error path.

The mechanism in isolation (no sentry involved)

The same work written both ways - nested functions like serialize() today, and methods on a per-call instance. RSS growth plus what the GC finds, for each variant:

import gc

import psutil


def closure_version():  # how serialize() is structured today
    def helper(n):
        return work(n - 1)

    def work(n):
        return helper(n) if n > 0 else 0

    work(3)


class ClassVersion:  # the proposed structure
    def helper(self, n):
        return self.work(n - 1)

    def work(self, n):
        return self.helper(n) if n > 0 else 0


def class_version():
    ClassVersion().work(3)


process = psutil.Process()
gc.collect()
gc.disable()
gc.set_debug(gc.DEBUG_SAVEALL)

# methods on a class: RSS flat, nothing for the GC to find
start = process.memory_info().rss
for _ in range(200_000):
    class_version()
gc.collect()
print(f'class:    {(process.memory_info().rss - start) / 2 ** 20:+6.1f} MiB')
print(f'class garbage:    {len(gc.garbage)}')

# nested functions: RSS grows, 6 cyclic objects leaked per call
# (two functions, their cells and __closure__ tuples)
start = process.memory_info().rss
for _ in range(200_000):
    closure_version()
gc.collect()
print(f'closures: {(process.memory_info().rss - start) / 2 ** 20:+6.1f} MiB')
print(f'closures garbage: {len(gc.garbage)}')
class:      +0.0 MiB
class garbage:    0
closures: +121.5 MiB
closures garbage: 1200000

Proposed fix

Move the nested functions onto a small per-call serializer class: the instance carries what the closures used to share (memo, path, meta_stack, options), and everything is freed by reference counting alone. Behaviour stays identical.

We monkey-patched the stock serializer with such a class-based version in our production services and it works great: memory is stable, and a parity test keeps its output identical to stock serialize() across event shapes.

If that would help, I can submit a PR with the fix and tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions