Skip to content

Memory leak: DedupeIntegration pins exception tracebacks in asyncio apps #6094

Closed

Description

@G000D1ESS

How do you use Sentry?

Self-hosted/on-premise

Version

2.56.0 (Python 3.12.6)

Steps to Reproduce

  1. sentry_sdk.init with only LoggingIntegration + DedupeIntegration enabled.
  2. In a long-lived asyncio task: build a local variable of ~1 MB so the frame carries that
    much data, raise ValueError, log with logger.exception(...), keep the task running.
  3. The exception, its traceback, and the 1 MB local stay alive for the lifetime of the task (and maybe after its death)

Expected Result

After logger.exception(...) returns and the except block exits, the exception, its
traceback, and all its frame locals become unreachable and memory are freed

Actual Result

The exception stays alive for the whole lifetime of the task. Memory grows with every new
captured exception on a new task, and the 1 MB local (plus the whole traceback chain)
stays pinned until the task itself ends.

RAM usage growth:
Image

What I found

DedupeIntegration._last_seen is a ContextVar that stores the last captured exception.
The SDK tries to store it as weakref.ref(exc), but Python builtins (ValueError,
TypeError, KeyError, ...) do not support weakref. On TypeError the SDK falls back to
a strong reference

Under asyncio every long-lived task keeps its own ContextVar state, so the strong
reference lives as long as the task. Through that reference the SDK pins:

  • the exception,
  • exc.__traceback__,
  • every frame local in the traceback.

In our web crawler frame locals held response payloads in the 500 KB-1 MB range, which is where
the RSS growth came from

Fix (what I did)

Replace the dedupe processor with one that stores weakref.ref(exc) when possible and
silently skips on TypeError. Dedupe is lost for builtins, leak is gone

Activity

  1. linear-code commented on Apr 19, 2026

    @linear-code
  2. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on Apr 19, 2026
  3. ericapisani commented on Apr 20, 2026

    @ericapisani
    Member

    Hi @G000D1ESS ,

    Thanks for opening an issue! We'll take a look at this as soon as possible.

  4. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on Apr 20, 2026
  5. G000D1ESS commented on May 26, 2026

    @G000D1ESS
    Author

    Hi, any updates?

  6. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on May 26, 2026
  7. ericapisani commented on May 26, 2026

    @ericapisani
    Member

    Hi @G000D1ESS ,

    Apologies for not providing an update on this sooner. We had taken a look at this, and we want to avoid introducing inconsistent behaviour in the SDK. So for now, we don't want to remove the deduping for built-ins just yet.

    We'd like to move towards a more robust fingerprinting approach that can also be used for built-in exceptions. However, this is likely to lead to a significant change in behaviour on the SDK's part on what's deduplicated and what isn't and, as the team is currently focused on shipping streamed spans for the next major, we're unlikely to get to addressing this until after we've shipped the next major release of the SDK.

  8. 1 remaining item

  9. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on May 26, 2026
  10. IgorGanapolsky commented on Jul 24, 2026

    @IgorGanapolsky
  11. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on Jul 24, 2026
  12. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on Jul 27, 2026
  13. JonathanSolvesProblems commented on Aug 18, 2026

    @JonathanSolvesProblems

    I'd like to take a run at this if it's still open.

    I know the team decided against dropping dedupe for builtins, and that a more robust fingerprinting approach was the preferred direction. I think there's a version of that with a small blast radius that fixes the leak now without changing anything for non-builtins.

    Keep weakref.ref(exc) as the primary path, so identity-based dedupe for non-builtin exceptions is unchanged. Replace only the except TypeError fallback, which is the line that leaks:

    try:
        integration._last_seen.set(weakref.ref(exc))
    except TypeError:
        integration._last_seen.set(exc)  # strong ref -> pins exc.__traceback__ and every frame local

    Instead of storing the exception, store a fingerprint derived from it: exception type, args, and the (filename, lineno, function) tuple for each traceback frame. That is all strings and ints, so nothing from the traceback stays reachable and the frame locals are freed as soon as the except block exits. Dedupe for builtins then compares fingerprints instead of identity.

    The behavior change is confined to builtins, where the fallback already deviates from the weakref path: two separately raised but identical builtin exceptions from the same line would now dedupe where previously they would not.

    If that direction sounds acceptable I'll put up a draft PR with tests and a before/after memory measurement on the reporter's repro. If you would rather hold this for the fingerprinting work in the next major, no problem, just let me know.

  14. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on Aug 18, 2026
  15. JonathanSolvesProblems commented on Aug 18, 2026

    @JonathanSolvesProblems

    Correcting my own suggestion above: the fingerprint approach does not work, and the existing tests say why.

    A value fingerprint cannot tell "the same exception object captured twice" apart from "the same error raised repeatedly from the same line". Two tests in tests/test_basics.py depend on that distinction, and both fail with a fingerprint:

    • test_breadcrumbs calls capture_exception(ValueError()) twice with two distinct, never-raised exceptions. No traceback and no message, so the fingerprints are identical and the second event is wrongly dropped.
    • test_option_before_breadcrumb calls do_this() three times, each raising a distinct ValueError("aha!") from the same line. Same type, same message, same origin frame, so events two and three are wrongly dropped.

    That is the behaviour change you were worried about, so scratch that idea.

    What does work is keeping identity semantics and only changing what holds them. Attach a small weak-referenceable token to the exception and weakly reference the token instead of the exception:

    class _DedupeToken:
        __slots__ = ("__weakref__",)

    There is one token per exception object and it lives exactly as long as the exception, so last_seen is token is equivalent to the old last_seen is exc, while the ContextVar no longer keeps the exception, its traceback, or any frame local alive. Exceptions that cannot carry a token fall back to the current behaviour.

    Measured on the reported scenario, 25 long-lived asyncio tasks each pinning a 1 MB frame local:

    payloads still reachable after gc traced memory retained
    before 25 / 25 25.5 MB
    after 0 / 25 0.5 MB

    Verification on Python 3.14: the existing dedupe tests pass unchanged, and running tests/test_basics.py, tests/test_client.py, tests/test_scope.py and tests/test_utils.py against the patch produces no new failures compared to the same run on an unpatched tree. ruff check, ruff format and mypy sentry_sdk are clean, with the one pre-existing pure_eval.py error present in both. I added three tests: one that fails without the fix because the frame local stays reachable, and two that pin the behaviour the fingerprint attempt broke.

    Branch: https://github.com/JonathanSolvesProblems/sentry-python/tree/fix/dedupe-builtin-exception-retention

    Since this keeps dedupe behaviour identical, it should not need to wait for the fingerprinting rework. Happy to open a PR if you want it, or to leave it if you would rather fold it into that larger change.

  16. self-assigned this
    on Sep 16, 2026
  17. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on Sep 22, 2026
  18. added a commit that references this issue on Oct 5, 2026
    dd7a983
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions