From d3d30ddfb262fc20a4710bf4c7a895724e557907 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 17 Sep 2026 17:07:46 -0400 Subject: [PATCH 01/10] Attempt #1 --- sentry_sdk/integrations/dedupe.py | 43 +++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index a0cc88081f..0fafcf38f0 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -1,4 +1,4 @@ -import weakref +import time from contextvars import ContextVar from typing import TYPE_CHECKING @@ -34,25 +34,36 @@ def processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": if exc_info is None: return event - last_seen = integration._last_seen.get(None) - if last_seen is not None: - # last_seen is either a weakref or the original instance - last_seen = ( - last_seen() if isinstance(last_seen, weakref.ref) else last_seen - ) + last_seen_entries = integration._last_seen.get(None) + updated_cache_entries = set() exc = exc_info[1] - if last_seen is exc: - logger.info("DedupeIntegration dropped duplicated error event %s", exc) - return None + now = time.time() + + if not last_seen_entries: + integration._last_seen.set([(exc, now)]) + return event + + found_duplicate = False + for cache_item in last_seen_entries: + exception_item, last_seen = cache_item + if last_seen < (now - 60): # 1 minute TTL + continue - # we can only weakref non builtin types - try: - integration._last_seen.set(weakref.ref(exc)) - except TypeError: - integration._last_seen.set(exc) + if exc is exception_item: + updated_cache_entries.add((exception_item, now)) + found_duplicate = True + continue - return event + updated_cache_entries.add((exception_item, last_seen)) + + integration._last_seen.set(updated_cache_entries) + + if found_duplicate: + logger.info("DedupeIntegration dropped duplicated error event %s", exc) + return None + else: + return event @staticmethod def reset_last_seen() -> None: From c2b64a75696ae3955af464fbb82f0f3cfa945e09 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 17 Sep 2026 17:20:58 -0400 Subject: [PATCH 02/10] attempt 1.5 --- sentry_sdk/integrations/dedupe.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 0fafcf38f0..404bf91868 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -57,6 +57,9 @@ def processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": updated_cache_entries.add((exception_item, last_seen)) + if not found_duplicate: + updated_cache_entries.add((exc, now)) + integration._last_seen.set(updated_cache_entries) if found_duplicate: From 15a9ecdefe3a999ec8ee0f59661611c2992457dc Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 18 Sep 2026 14:53:30 -0400 Subject: [PATCH 03/10] set private variable --- sentry_sdk/client.py | 7 ----- sentry_sdk/integrations/dedupe.py | 43 +++---------------------------- tests/test_basics.py | 24 +++++++++++++++-- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 9114cf6b67..dff8492146 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -607,13 +607,6 @@ def _prepare_event( "before_send", data_category="error" ) - # If this is an exception, reset the DedupeIntegration. It still - # remembers the dropped exception as the last exception, meaning - # that if the same exception happens again and is not dropped - # in before_send, it'd get dropped by DedupeIntegration. - if event.get("exception"): - DedupeIntegration.reset_last_seen() - event = new_event return event diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 404bf91868..27f0654c2e 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -5,7 +5,7 @@ import sentry_sdk from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.utils import logger +from sentry_sdk.utils import capture_internal_exceptions, logger if TYPE_CHECKING: from typing import Any, Optional @@ -16,9 +16,6 @@ class DedupeIntegration(Integration): identifier = "dedupe" - def __init__(self) -> None: - self._last_seen: "ContextVar[Any]" = ContextVar("last-seen") - @staticmethod def setup_once() -> None: @add_global_event_processor @@ -34,44 +31,12 @@ def processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]": if exc_info is None: return event - last_seen_entries = integration._last_seen.get(None) - updated_cache_entries = set() - exc = exc_info[1] - now = time.time() - - if not last_seen_entries: - integration._last_seen.set([(exc, now)]) - return event - - found_duplicate = False - for cache_item in last_seen_entries: - exception_item, last_seen = cache_item - if last_seen < (now - 60): # 1 minute TTL - continue - - if exc is exception_item: - updated_cache_entries.add((exception_item, now)) - found_duplicate = True - continue - updated_cache_entries.add((exception_item, last_seen)) - - if not found_duplicate: - updated_cache_entries.add((exc, now)) - - integration._last_seen.set(updated_cache_entries) - - if found_duplicate: + if getattr(exc, "_handled_by_sentry", False): logger.info("DedupeIntegration dropped duplicated error event %s", exc) return None else: + with capture_internal_exceptions(): + exc._handled_by_sentry = True return event - - @staticmethod - def reset_last_seen() -> None: - integration = sentry_sdk.get_client().get_integration(DedupeIntegration) - if integration is None: - return - - integration._last_seen.set(None) diff --git a/tests/test_basics.py b/tests/test_basics.py index eb4eb516fe..2ca72b5091 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -614,13 +614,33 @@ def before_send(event, hint): sentry_init(before_send=before_send) events = capture_events() - exc = ValueError("aha!") for _ in range(2): # The first ValueError will be dropped by before_send. The second # ValueError will be accepted by before_send, and should be sent to # Sentry. try: - raise exc + raise ValueError("aha!") + except Exception: + capture_exception() + + assert len(events) == 1 + +def test_dedupe_drops_exception_when_seen_a_second_time(sentry_init, capture_events): + """ + This test is intended to emulate behavior seen in frameworks like Django, + where an exception is raised in a view and then is re-raised in middleware. + + In cases like that we don't want to send a second event for that exception. + """ + sentry_init() + events = capture_events() + + test = None + for _ in range(2): + try: + if test is None: + test = ValueError("foo") + raise test except Exception: capture_exception() From d0848088bb3ba1ccf0773744f532c403218955c2 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 18 Sep 2026 14:53:59 -0400 Subject: [PATCH 04/10] lint --- sentry_sdk/integrations/dedupe.py | 2 -- tests/test_basics.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 27f0654c2e..3e6206aa3c 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -1,5 +1,3 @@ -import time -from contextvars import ContextVar from typing import TYPE_CHECKING import sentry_sdk diff --git a/tests/test_basics.py b/tests/test_basics.py index 2ca72b5091..1b16257fe8 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -625,6 +625,7 @@ def before_send(event, hint): assert len(events) == 1 + def test_dedupe_drops_exception_when_seen_a_second_time(sentry_init, capture_events): """ This test is intended to emulate behavior seen in frameworks like Django, From b51dfd501e1b9a8d16d51a2d9aedb91480da2886 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 18 Sep 2026 14:56:50 -0400 Subject: [PATCH 05/10] lint --- sentry_sdk/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index dff8492146..4e3946c395 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -24,7 +24,6 @@ ) from sentry_sdk.envelope import Envelope, Item from sentry_sdk.integrations import setup_integrations -from sentry_sdk.integrations.dedupe import DedupeIntegration from sentry_sdk.monitor import Monitor from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler from sentry_sdk.scrubber import EventScrubber From 900ad90155e61d58bd58efc9e35c45940816d38b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 18 Sep 2026 15:00:33 -0400 Subject: [PATCH 06/10] . --- sentry_sdk/integrations/dedupe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 3e6206aa3c..a79b05bd29 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -6,7 +6,7 @@ from sentry_sdk.utils import capture_internal_exceptions, logger if TYPE_CHECKING: - from typing import Any, Optional + from typing import Optional from sentry_sdk._types import Event, Hint From 6a41257b1b742049d8fcb98b501628effcde6cd3 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 5 Oct 2026 06:30:46 -0400 Subject: [PATCH 07/10] add a more robust test to confirm the behaviour does not regress --- tests/test_basics.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_basics.py b/tests/test_basics.py index f7ff08961c..5a9cda34ea 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,14 +1,17 @@ import datetime +import gc import importlib import logging import os import sys import time from collections import Counter +import weakref import pytest import sentry_sdk +from sentry_sdk.integrations.dedupe import DedupeIntegration import sentry_sdk.scope from sentry_sdk import ( add_breadcrumb, @@ -645,6 +648,38 @@ def test_dedupe_drops_exception_when_seen_a_second_time(sentry_init, capture_eve assert len(events) == 1 +def test_dedupe_does_not_retain_builtin_exceptions(sentry_init): + """ + There was a different approach that used to be used by DedupeIntegration + that used a weakref to hold a reference to a seen exception, and then do a comparison + on an incoming exception with that weakref to determine if it was a duplicate. + + Built in exceptions such as ValueError couldn't be used with weakref, so we would instead + hold a strong reference to that exception. However, this led to memory leaks as described in + https://github.com/getsentry/sentry-python/issues/6094 + """ + sentry_init(default_integrations=False, integrations=[DedupeIntegration()]) + + class Payload: + pass + + payload_ref = None + + def fail(): + nonlocal payload_ref + payload = Payload() + payload_ref = weakref.ref(payload) + raise ValueError("boom") + + try: + fail() + except ValueError as e: + sentry_sdk.capture_exception(e) + + gc.collect() + assert payload_ref() is None + + def test_event_processor_drop_records_client_report( sentry_init, capture_events, capture_record_lost_event_calls ): From 32156f9373cf11092a52049eb6daa2709d6764e4 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 5 Oct 2026 06:33:39 -0400 Subject: [PATCH 08/10] . --- tests/test_basics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index 5a9cda34ea..90e71b5a4f 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,4 +1,3 @@ -import datetime import gc import importlib import logging From 77b634be2f7f0a56423a51bb236ab48d81992920 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 5 Oct 2026 07:32:56 -0400 Subject: [PATCH 09/10] . --- tests/test_basics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index 90e71b5a4f..db27e9609d 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,16 +1,16 @@ +import datetime import gc import importlib import logging import os import sys import time -from collections import Counter import weakref +from collections import Counter import pytest import sentry_sdk -from sentry_sdk.integrations.dedupe import DedupeIntegration import sentry_sdk.scope from sentry_sdk import ( add_breadcrumb, @@ -29,6 +29,7 @@ Integration, setup_integrations, ) +from sentry_sdk.integrations.dedupe import DedupeIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.scope import add_global_event_processor From 63e1e485016106f548855fc6ac28060b59cd0b68 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 5 Oct 2026 08:12:36 -0400 Subject: [PATCH 10/10] test(dedupe): Keep exception out of test frame locals in retention test The test asserted that a payload referenced from a captured exception's traceback is garbage collected. Binding the exception in the test function's own frame kept it reachable via the frame's locals under coverage tracing, so the assertion failed on CI (seen on 3.8 and 3.12). Raise and capture inside a nested helper so the exception never lands in the test function's locals. --- tests/test_basics.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index db27e9609d..ce640b4c9e 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -671,10 +671,13 @@ def fail(): payload_ref = weakref.ref(payload) raise ValueError("boom") - try: - fail() - except ValueError as e: - sentry_sdk.capture_exception(e) + def capture(): + try: + fail() + except ValueError as e: + sentry_sdk.capture_exception(e) + + capture() gc.collect() assert payload_ref() is None