From d0d0d0a0170f9c8097e67d462b2ca6c7e8c3847a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 5 Nov 2026 06:58:02 +0100 Subject: [PATCH 1/2] fix(core): Exclude reusable errors from deduplication Allow exception class names and patterns to bypass throwable-based event deduplication. Exclude Netty ReadTimeoutException by default so its static singleton does not suppress independent timeout occurrences for the SDK lifetime. Fixes #6365 --- sentry/api/sentry.api | 3 + ...DuplicateEventDetectionEventProcessor.java | 30 +++++++--- .../main/java/io/sentry/SentryOptions.java | 51 +++++++++++++++++ ...plicateEventDetectionEventProcessorTest.kt | 55 +++++++++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 21 +++++++ 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9affbccd017..489dab47bbc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3480,6 +3480,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun addEventProcessor (Lio/sentry/EventProcessor;)V public fun addIgnoredCheckIn (Ljava/lang/String;)V public fun addIgnoredError (Ljava/lang/String;)V + public fun addIgnoredErrorForDeduplication (Ljava/lang/String;)V public fun addIgnoredExceptionForType (Ljava/lang/Class;)V public fun addIgnoredSpanOrigin (Ljava/lang/String;)V public fun addIgnoredTransaction (Ljava/lang/String;)V @@ -3537,6 +3538,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun getIdleTimeout ()Ljava/lang/Long; public fun getIgnoredCheckIns ()Ljava/util/List; public fun getIgnoredErrors ()Ljava/util/List; + public fun getIgnoredErrorsForDeduplication ()Ljava/util/List; public fun getIgnoredExceptionsForType ()Ljava/util/Set; public fun getIgnoredSpanOrigins ()Ljava/util/List; public fun getIgnoredTransactions ()Ljava/util/List; @@ -3708,6 +3710,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun setIdleTimeout (Ljava/lang/Long;)V public fun setIgnoredCheckIns (Ljava/util/List;)V public fun setIgnoredErrors (Ljava/util/List;)V + public fun setIgnoredErrorsForDeduplication (Ljava/util/List;)V public fun setIgnoredSpanOrigins (Ljava/util/List;)V public fun setIgnoredTransactions (Ljava/util/List;)V public fun setInitPriority (Lio/sentry/InitPriority;)V diff --git a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java index c3f02e03141..49f2453b5b2 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -26,8 +26,9 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option if (options.isEnableDeduplication()) { final Throwable throwable = event.getThrowable(); if (throwable != null) { - if (capturedObjects.containsKey(throwable) - || containsAnyKey(capturedObjects, allCauses(throwable))) { + final boolean ignoredForDeduplication = isIgnoredForDeduplication(throwable); + if ((!ignoredForDeduplication && capturedObjects.containsKey(throwable)) + || containsAnyKey(allCauses(throwable))) { options .getLogger() .log( @@ -35,7 +36,7 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option "Duplicate Exception detected. Event %s will be discarded.", event.getEventId()); return null; - } else { + } else if (!ignoredForDeduplication) { capturedObjects.put(throwable, null); } } @@ -45,10 +46,25 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option return event; } - private static boolean containsAnyKey( - final @NotNull Map map, final @NotNull List list) { - for (T entry : list) { - if (map.containsKey(entry)) { + private boolean containsAnyKey(final @NotNull List throwables) { + for (final Throwable throwable : throwables) { + if (!isIgnoredForDeduplication(throwable) && capturedObjects.containsKey(throwable)) { + return true; + } + } + return false; + } + + private boolean isIgnoredForDeduplication(final @NotNull Throwable throwable) { + final @Nullable List ignoredErrors = options.getIgnoredErrorsForDeduplication(); + if (ignoredErrors == null || ignoredErrors.isEmpty()) { + return false; + } + + final String exceptionClassName = throwable.getClass().getName(); + for (final FilterString ignoredError : ignoredErrors) { + if (ignoredError.getFilterString().equals(exceptionClassName) + || ignoredError.matches(exceptionClassName)) { return true; } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index fad2deefdd8..d49094e04e1 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -78,6 +78,9 @@ public class SentryOptions implements RateLimiterConfig { */ private static final String DEFAULT_ENVIRONMENT = "production"; + private static final String DEFAULT_IGNORED_ERROR_FOR_DEDUPLICATION = + "io.netty.handler.timeout.ReadTimeoutException"; + /** * Are callbacks that run for every event. They can either return a new event which in most cases * means just adding data OR return null in case the event will be dropped and not sent. @@ -387,6 +390,11 @@ public class SentryOptions implements RateLimiterConfig { */ private boolean enableDeduplication = true; + /** Exception types that are excluded from event deduplication. */ + private @Nullable List ignoredErrorsForDeduplication = + new ArrayList<>( + Collections.singletonList(new FilterString(DEFAULT_IGNORED_ERROR_FOR_DEDUPLICATION))); + /** * Enables event size limiting with {@link EventSizeLimitingEventProcessor}. When enabled, events * exceeding 1MB will have breadcrumbs and stack frames reduced to stay under the limit. @@ -1947,6 +1955,49 @@ public void setEnableDeduplication(final boolean enableDeduplication) { this.enableDeduplication = enableDeduplication; } + /** + * Returns the list of exception class names excluded from event deduplication. + * + * @return the list of exception class names excluded from event deduplication + */ + public @Nullable List getIgnoredErrorsForDeduplication() { + return ignoredErrorsForDeduplication; + } + + /** + * Sets the list of exception class names excluded from event deduplication. Entries may be exact + * class names or regular expressions. + * + * @param ignoredErrorsForDeduplication exception class names or regular expressions + */ + public void setIgnoredErrorsForDeduplication( + final @Nullable List ignoredErrorsForDeduplication) { + if (ignoredErrorsForDeduplication == null) { + this.ignoredErrorsForDeduplication = null; + } else { + final @NotNull List patterns = new ArrayList<>(); + for (final String pattern : ignoredErrorsForDeduplication) { + if (pattern != null && !pattern.isEmpty()) { + patterns.add(new FilterString(pattern)); + } + } + this.ignoredErrorsForDeduplication = patterns; + } + } + + /** + * Adds an exception class name or regular expression to the list excluded from event + * deduplication. + * + * @param pattern the exception class name or regular expression + */ + public void addIgnoredErrorForDeduplication(final @NotNull String pattern) { + if (ignoredErrorsForDeduplication == null) { + ignoredErrorsForDeduplication = new ArrayList<>(); + } + ignoredErrorsForDeduplication.add(new FilterString(pattern)); + } + /** * Returns if event size limiting is enabled. * diff --git a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt index 8fb929224ea..5f62234d06f 100644 --- a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import java.lang.RuntimeException @@ -8,6 +9,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull class DuplicateEventDetectionEventProcessorTest { + private class IgnoredForDeduplicationException(cause: Throwable? = null) : RuntimeException(cause) + private class CircularCauseThrowable : RuntimeException() { private var nextCause: Throwable? = null private var causeReads = 0 @@ -57,6 +60,58 @@ class DuplicateEventDetectionEventProcessorTest { assertNull(result) } + @Test + fun `does not add ignored exception to captured exceptions`() { + val options = + SentryOptions().apply { + setIgnoredErrorsForDeduplication(listOf(IgnoredForDeduplicationException::class.java.name)) + } + val processor = DuplicateEventDetectionEventProcessor(options) + val event = SentryEvent(IgnoredForDeduplicationException()) + + assertThat(processor.process(event, Hint())).isNotNull() + options.setIgnoredErrorsForDeduplication(emptyList()) + assertThat(processor.process(event, Hint())).isNotNull() + assertThat(processor.process(event, Hint())).isNull() + } + + @Test + fun `does not check ignored exception against captured exceptions`() { + val options = SentryOptions().apply { setIgnoredErrorsForDeduplication(emptyList()) } + val processor = DuplicateEventDetectionEventProcessor(options) + val event = SentryEvent(IgnoredForDeduplicationException()) + assertThat(processor.process(event, Hint())).isNotNull() + + options.addIgnoredErrorForDeduplication(IgnoredForDeduplicationException::class.java.name) + + assertThat(processor.process(event, Hint())).isNotNull() + } + + @Test + fun `does not check ignored cause against captured exceptions`() { + val options = SentryOptions().apply { setIgnoredErrorsForDeduplication(emptyList()) } + val processor = DuplicateEventDetectionEventProcessor(options) + val cause = IgnoredForDeduplicationException() + assertThat(processor.process(SentryEvent(cause), Hint())).isNotNull() + + options.addIgnoredErrorForDeduplication(IgnoredForDeduplicationException::class.java.name) + + assertThat(processor.process(SentryEvent(RuntimeException(cause)), Hint())).isNotNull() + } + + @Test + fun `ignored exception patterns are matched against the fully qualified class name`() { + val options = + SentryOptions().apply { + setIgnoredErrorsForDeduplication(listOf(".*IgnoredForDeduplicationException")) + } + val processor = DuplicateEventDetectionEventProcessor(options) + val event = SentryEvent(IgnoredForDeduplicationException()) + + assertThat(processor.process(event, Hint())).isNotNull() + assertThat(processor.process(event, Hint())).isNotNull() + } + @Test fun `drops event with mechanism exception having an exception that has already been processed`() { val processor = fixture.getSut() diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index e8e9046cfe1..f2b7ee78b46 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1049,6 +1049,27 @@ class SentryOptionsTest { assertEquals(SentryOptions().initPriority, InitPriority.MEDIUM) } + @Test + fun `ignored errors for deduplication contains Netty read timeout by default`() { + assertThat(SentryOptions().ignoredErrorsForDeduplication) + .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + } + + @Test + fun `ignored errors for deduplication can be replaced and extended`() { + val options = SentryOptions() + + options.setIgnoredErrorsForDeduplication(listOf("example.FirstException", "")) + options.addIgnoredErrorForDeduplication("example.SecondException") + + assertThat(options.ignoredErrorsForDeduplication) + .containsExactly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) + .inOrder() + } + @Test fun `merging options when ignoredErrors is not set preserves the previous value`() { val externalOptions = ExternalOptions() From 7a9e14e8e8ba777ca3a768a2118e196197f69fee Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 5 Nov 2026 07:18:35 +0100 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d6b1dbe1d..85f7af75d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ - The form never used `AlertDialog` features. Code that calls `AlertDialog`-only methods on it, such as `setButton` or `setMessage`, no longer compiles. - `sentry-android` no longer includes `sentry-android-ndk` as a default dependency. Instead, tombstone reporting is enabled by default, which provides more detailed native crashes on Android 11+ (API 30). If you're using sentry-android-ndk APIs, or need native crashes on Android 10 and lower, manually add the `io.sentry:sentry-android-ndk` dependency to your app. ([#6436](https://github.com/getsentry/sentry-java/pull/6436)) +### Fixes + +- Preserve independent Netty read timeout events when the exception singleton is reused ([#6465](https://github.com/getsentry/sentry-java/pull/6465)) + ### Improvements - Promote check-in APIs from experimental to stable ([#6422](https://github.com/getsentry/sentry-java/pull/6422))