From d0d0d0a0170f9c8097e67d462b2ca6c7e8c3847a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 5 Nov 2026 06:58:02 +0100 Subject: [PATCH 1/7] 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/7] 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)) From 4e5a867b9e7e9bd25e17fcc2eea350bb7486d9b7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 6 Nov 2026 12:00:01 +0100 Subject: [PATCH 3/7] fix(core): Support dedup exclusions in option sources Allow deduplication exclusions to be configured through external properties, Android manifest metadata, and Spring Boot binding. Preserve built-in defaults when unset and replace them when explicitly configured, including empty lists. Refs GH-6365 Co-Authored-By: Claude --- .../android/core/ManifestMetadataReader.java | 9 ++++ .../core/ManifestMetadataReaderTest.kt | 39 ++++++++++++++++ .../boot4/SentryAutoConfigurationTest.kt | 19 ++++++++ .../jakarta/SentryAutoConfigurationTest.kt | 19 ++++++++ .../boot/SentryAutoConfigurationTest.kt | 19 ++++++++ sentry/api/sentry.api | 2 + .../main/java/io/sentry/ExternalOptions.java | 12 +++++ .../main/java/io/sentry/SentryOptions.java | 5 +++ .../java/io/sentry/ExternalOptionsTest.kt | 23 ++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 44 +++++++++++++++++++ 10 files changed, 191 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index b6e802bbd5f..9158e65e6cf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -188,6 +188,9 @@ final class ManifestMetadataReader { static final String IGNORED_ERRORS = "io.sentry.ignored-errors"; + static final String IGNORED_ERRORS_FOR_DEDUPLICATION = + "io.sentry.ignored-errors-for-deduplication"; + static final String IN_APP_INCLUDES = "io.sentry.in-app-includes"; static final String IN_APP_EXCLUDES = "io.sentry.in-app-excludes"; @@ -736,6 +739,12 @@ static void applyMetadata( options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); + final @Nullable List ignoredErrorsForDeduplication = + readList(metadata, logger, IGNORED_ERRORS_FOR_DEDUPLICATION); + if (ignoredErrorsForDeduplication != null) { + options.setIgnoredErrorsForDeduplication(ignoredErrorsForDeduplication); + } + final @Nullable List includes = readList(metadata, logger, IN_APP_INCLUDES); if (includes != null && !includes.isEmpty()) { for (final @NotNull String include : includes) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 7c52f8959a7..70743583db7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2113,6 +2113,45 @@ class ManifestMetadataReaderTest { ) } + @Test + fun `applyMetadata reads ignored errors for deduplication if found`() { + val bundle = + bundleOf( + ManifestMetadataReader.IGNORED_ERRORS_FOR_DEDUPLICATION to + "example.FirstException,example.SecondException" + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.ignoredErrorsForDeduplication) + .containsExactly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) + .inOrder() + } + + @Test + fun `applyMetadata preserves default ignored errors for deduplication if missing`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.ignoredErrorsForDeduplication) + .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + } + + @Test + fun `applyMetadata empty ignored errors for deduplication removes the default`() { + val bundle = bundleOf(ManifestMetadataReader.IGNORED_ERRORS_FOR_DEDUPLICATION to "") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.ignoredErrorsForDeduplication).isEmpty() + } + @Test fun `applyMetadata reads inAppIncludes to options and sets the value if found`() { // Arrange diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index 9aa8cb006d1..65dab759de5 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -228,6 +228,7 @@ class SentryAutoConfigurationTest { "sentry.send-modules=false", "sentry.ignored-checkins=slug1,slugB", "sentry.ignored-errors=Some error,Another .*", + "sentry.ignored-errors-for-deduplication=example.FirstException,example.SecondException", "sentry.ignored-transactions=transactionName1,transactionNameB", "sentry.enable-backpressure-handling=false", "sentry.enable-database-transaction-tracing=true", @@ -283,6 +284,11 @@ class SentryAutoConfigurationTest { .containsOnly(FilterString("slug1"), FilterString("slugB")) assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) @@ -357,6 +363,19 @@ class SentryAutoConfigurationTest { } } + @Test + fun `empty ignored errors for deduplication removes the default`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.ignored-errors-for-deduplication=", + ) + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.ignoredErrorsForDeduplication).isEmpty() + } + } + @Test fun `when traces sample rate is set to null and tracing is enabled, traces sample rate should be set to 0`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index c3a8fd2506b..58daecdcd7e 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -231,6 +231,7 @@ class SentryAutoConfigurationTest { "sentry.send-modules=false", "sentry.ignored-checkins=slug1,slugB", "sentry.ignored-errors=Some error,Another .*", + "sentry.ignored-errors-for-deduplication=example.FirstException,example.SecondException", "sentry.ignored-transactions=transactionName1,transactionNameB", "sentry.enable-backpressure-handling=false", "sentry.enable-database-transaction-tracing=true", @@ -288,6 +289,11 @@ class SentryAutoConfigurationTest { .containsOnly(FilterString("slug1"), FilterString("slugB")) assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) @@ -365,6 +371,19 @@ class SentryAutoConfigurationTest { } } + @Test + fun `empty ignored errors for deduplication removes the default`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.ignored-errors-for-deduplication=", + ) + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.ignoredErrorsForDeduplication).isEmpty() + } + } + @Test fun `when traces sample rate is set to null and tracing is enabled, traces sample rate should be set to 0`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 315a3c1449b..ca50eb65e13 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -229,6 +229,7 @@ class SentryAutoConfigurationTest { "sentry.send-modules=false", "sentry.ignored-checkins=slug1,slugB", "sentry.ignored-errors=Some error,Another .*", + "sentry.ignored-errors-for-deduplication=example.FirstException,example.SecondException", "sentry.ignored-transactions=transactionName1,transactionNameB", "sentry.enable-backpressure-handling=false", "sentry.enable-database-transaction-tracing=true", @@ -286,6 +287,11 @@ class SentryAutoConfigurationTest { .containsOnly(FilterString("slug1"), FilterString("slugB")) assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) @@ -363,6 +369,19 @@ class SentryAutoConfigurationTest { } } + @Test + fun `empty ignored errors for deduplication removes the default`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.ignored-errors-for-deduplication=", + ) + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.ignoredErrorsForDeduplication).isEmpty() + } + } + @Test fun `when traces sample rate is set to null and tracing is enabled, traces sample rate should be set to 0`() { dsnEnabledRunner.run { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 489dab47bbc..26d4e7b5db0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -564,6 +564,7 @@ public final class io/sentry/ExternalOptions { 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 getIgnoredTransactions ()Ljava/util/List; public fun getInAppExcludes ()Ljava/util/List; @@ -621,6 +622,7 @@ public final class io/sentry/ExternalOptions { 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 setIgnoredTransactions (Ljava/util/List;)V public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setOrgId (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index efeb005f7b5..168e582379c 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -40,6 +40,7 @@ public final class ExternalOptions { private final @NotNull Set> ignoredExceptionsForType = new CopyOnWriteArraySet<>(); private @Nullable List ignoredErrors; + private @Nullable List ignoredErrorsForDeduplication; private @Nullable Boolean printUncaughtStackTrace; private @Nullable Boolean sendClientReports; private @NotNull Set bundleIds = new CopyOnWriteArraySet<>(); @@ -148,6 +149,8 @@ public final class ExternalOptions { propertiesProvider.getLongProperty("session-flush-timeout-millis")); options.setIgnoredErrors(propertiesProvider.getListOrNull("ignored-errors")); + options.setIgnoredErrorsForDeduplication( + propertiesProvider.getListOrNull("ignored-errors-for-deduplication")); options.setEnabled(propertiesProvider.getBooleanProperty("enabled")); @@ -549,6 +552,15 @@ public void setIgnoredErrors(final @Nullable List ignoredErrors) { this.ignoredErrors = ignoredErrors; } + public @Nullable List getIgnoredErrorsForDeduplication() { + return ignoredErrorsForDeduplication; + } + + public void setIgnoredErrorsForDeduplication( + final @Nullable List ignoredErrorsForDeduplication) { + this.ignoredErrorsForDeduplication = ignoredErrorsForDeduplication; + } + public @Nullable Boolean getSendClientReports() { return sendClientReports; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index d49094e04e1..b88b10238fa 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3809,6 +3809,11 @@ public void merge(final @NotNull ExternalOptions options) { final List ignoredExceptions = new ArrayList<>(options.getIgnoredErrors()); setIgnoredErrors(ignoredExceptions); } + if (options.getIgnoredErrorsForDeduplication() != null) { + final List ignoredErrorsForDeduplication = + new ArrayList<>(options.getIgnoredErrorsForDeduplication()); + setIgnoredErrorsForDeduplication(ignoredErrorsForDeduplication); + } if (options.isEnableBackpressureHandling() != null) { setEnableBackpressureHandling(options.isEnableBackpressureHandling()); } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 349058e4b3b..06fb29430b7 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -333,6 +333,29 @@ class ExternalOptionsTest { withPropertiesFile("Another .*", logger) { options -> assertNull(options.ignoredErrors) } } + @Test + fun `creates options with ignored errors for deduplication using external properties`() { + withPropertiesFile( + "ignored-errors-for-deduplication=example.FirstException,example.SecondException" + ) { options -> + assertThat(options.ignoredErrorsForDeduplication) + .containsExactly("example.FirstException", "example.SecondException") + .inOrder() + } + } + + @Test + fun `creates options with empty ignored errors for deduplication using external properties`() { + withPropertiesFile("ignored-errors-for-deduplication=") { options -> + assertThat(options.ignoredErrorsForDeduplication).containsExactly("") + } + } + + @Test + fun `creates options with null ignored errors for deduplication if missing`() { + withPropertiesFile { options -> assertThat(options.ignoredErrorsForDeduplication).isNull() } + } + @Test fun `creates options with single bundle ID using external properties`() { withPropertiesFile("bundle-ids=12ea7a02-46ac-44c0-a5bb-6d1fd9586411") { options -> diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index f2b7ee78b46..f29edc4b64a 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -571,6 +571,8 @@ class SentryOptionsTest { externalOptions.ignoredCheckIns = listOf("slug1", "slug-B") externalOptions.ignoredTransactions = listOf("transactionName1", "transaction-name-B") externalOptions.ignoredErrors = listOf("Some error", "Another .*") + externalOptions.ignoredErrorsForDeduplication = + listOf("example.FirstException", "example.SecondException") externalOptions.isEnableBackpressureHandling = false externalOptions.isEnableDatabaseTransactionTracing = true externalOptions.isEnableCacheTracing = true @@ -635,6 +637,10 @@ class SentryOptionsTest { listOf(FilterString("Some error"), FilterString("Another .*")), options.ignoredErrors, ) + assertEquals( + listOf(FilterString("example.FirstException"), FilterString("example.SecondException")), + options.ignoredErrorsForDeduplication, + ) assertFalse(options.isEnableBackpressureHandling) assertTrue(options.isEnableDatabaseTransactionTracing) assertTrue(options.isEnableCacheTracing) @@ -1070,6 +1076,44 @@ class SentryOptionsTest { .inOrder() } + @Test + fun `merging options without ignored errors for deduplication preserves the default`() { + val options = SentryOptions() + + options.merge(ExternalOptions()) + + assertThat(options.ignoredErrorsForDeduplication) + .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + } + + @Test + fun `merging ignored errors for deduplication replaces the default`() { + val options = SentryOptions() + val externalOptions = + ExternalOptions().apply { + ignoredErrorsForDeduplication = listOf("example.FirstException", "example.SecondException") + } + + options.merge(externalOptions) + + assertThat(options.ignoredErrorsForDeduplication) + .containsExactly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) + .inOrder() + } + + @Test + fun `merging empty ignored errors for deduplication removes the default`() { + val options = SentryOptions() + val externalOptions = ExternalOptions().apply { ignoredErrorsForDeduplication = listOf("") } + + options.merge(externalOptions) + + assertThat(options.ignoredErrorsForDeduplication).isEmpty() + } + @Test fun `merging options when ignoredErrors is not set preserves the previous value`() { val externalOptions = ExternalOptions() From 826d6cc235963e2089cc8fe87a4111a5e9e0ecfd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 6 Nov 2026 12:08:46 +0100 Subject: [PATCH 4/7] fix(core): Align dedup exclusion getter API Return deduplication exclusions as strings while retaining compiled filters through an internal getter. This keeps the public getter and setter symmetric and prevents callers from mutating internal state. Refs #6365 Co-Authored-By: Claude --- .../core/ManifestMetadataReaderTest.kt | 7 ++-- .../boot4/SentryAutoConfigurationTest.kt | 5 +-- .../jakarta/SentryAutoConfigurationTest.kt | 5 +-- .../boot/SentryAutoConfigurationTest.kt | 5 +-- sentry/api/sentry.api | 1 + ...DuplicateEventDetectionEventProcessor.java | 3 +- .../main/java/io/sentry/SentryOptions.java | 21 +++++++++++- .../test/java/io/sentry/SentryOptionsTest.kt | 33 +++++++++++++++---- 8 files changed, 54 insertions(+), 26 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 70743583db7..2c0d0d8306e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2125,10 +2125,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) assertThat(fixture.options.ignoredErrorsForDeduplication) - .containsExactly( - FilterString("example.FirstException"), - FilterString("example.SecondException"), - ) + .containsExactly("example.FirstException", "example.SecondException") .inOrder() } @@ -2139,7 +2136,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) assertThat(fixture.options.ignoredErrorsForDeduplication) - .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + .containsExactly("io.netty.handler.timeout.ReadTimeoutException") } @Test diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index 65dab759de5..7e7ec8c1b3e 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -285,10 +285,7 @@ class SentryAutoConfigurationTest { assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) assertThat(options.ignoredErrorsForDeduplication) - .containsOnly( - FilterString("example.FirstException"), - FilterString("example.SecondException"), - ) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 58daecdcd7e..e4f0f206ddf 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -290,10 +290,7 @@ class SentryAutoConfigurationTest { assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) assertThat(options.ignoredErrorsForDeduplication) - .containsOnly( - FilterString("example.FirstException"), - FilterString("example.SecondException"), - ) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index ca50eb65e13..2f19a022e87 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -288,10 +288,7 @@ class SentryAutoConfigurationTest { assertThat(options.ignoredErrors) .containsOnly(FilterString("Some error"), FilterString("Another .*")) assertThat(options.ignoredErrorsForDeduplication) - .containsOnly( - FilterString("example.FirstException"), - FilterString("example.SecondException"), - ) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions) .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 26d4e7b5db0..c0320314c14 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3539,6 +3539,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun getHostnameCache ()Lio/sentry/HostnameCache; public fun getIdleTimeout ()Ljava/lang/Long; public fun getIgnoredCheckIns ()Ljava/util/List; + public fun getIgnoredErrorFiltersForDeduplication ()Ljava/util/List; public fun getIgnoredErrors ()Ljava/util/List; public fun getIgnoredErrorsForDeduplication ()Ljava/util/List; public fun getIgnoredExceptionsForType ()Ljava/util/Set; diff --git a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java index 49f2453b5b2..8013ec98c88 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -56,7 +56,8 @@ private boolean containsAnyKey(final @NotNull List throwables) { } private boolean isIgnoredForDeduplication(final @NotNull Throwable throwable) { - final @Nullable List ignoredErrors = options.getIgnoredErrorsForDeduplication(); + final @Nullable List ignoredErrors = + options.getIgnoredErrorFiltersForDeduplication(); if (ignoredErrors == null || ignoredErrors.isEmpty()) { return false; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index b88b10238fa..41be9303f23 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1958,9 +1958,28 @@ public void setEnableDeduplication(final boolean enableDeduplication) { /** * Returns the list of exception class names excluded from event deduplication. * + *

The returned list is a copy. Use the setter or adder to change the configured values. + * * @return the list of exception class names excluded from event deduplication */ - public @Nullable List getIgnoredErrorsForDeduplication() { + public @Nullable List getIgnoredErrorsForDeduplication() { + if (ignoredErrorsForDeduplication == null) { + return null; + } + final @NotNull List strings = new ArrayList<>(ignoredErrorsForDeduplication.size()); + for (final @NotNull FilterString filter : ignoredErrorsForDeduplication) { + strings.add(filter.getFilterString()); + } + return strings; + } + + /** + * Returns the compiled filters for {@link #getIgnoredErrorsForDeduplication()}. + * + * @return the compiled filters + */ + @ApiStatus.Internal + public @Nullable List getIgnoredErrorFiltersForDeduplication() { return ignoredErrorsForDeduplication; } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index f29edc4b64a..bb078b575ca 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -638,7 +638,7 @@ class SentryOptionsTest { options.ignoredErrors, ) assertEquals( - listOf(FilterString("example.FirstException"), FilterString("example.SecondException")), + listOf("example.FirstException", "example.SecondException"), options.ignoredErrorsForDeduplication, ) assertFalse(options.isEnableBackpressureHandling) @@ -1058,7 +1058,7 @@ class SentryOptionsTest { @Test fun `ignored errors for deduplication contains Netty read timeout by default`() { assertThat(SentryOptions().ignoredErrorsForDeduplication) - .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + .containsExactly("io.netty.handler.timeout.ReadTimeoutException") } @Test @@ -1069,6 +1069,9 @@ class SentryOptionsTest { options.addIgnoredErrorForDeduplication("example.SecondException") assertThat(options.ignoredErrorsForDeduplication) + .containsExactly("example.FirstException", "example.SecondException") + .inOrder() + assertThat(options.ignoredErrorFiltersForDeduplication) .containsExactly( FilterString("example.FirstException"), FilterString("example.SecondException"), @@ -1076,6 +1079,25 @@ class SentryOptionsTest { .inOrder() } + @Test + fun `ignored errors for deduplication getter returns a copy`() { + val options = SentryOptions() + options.setIgnoredErrorsForDeduplication(listOf("example.FirstException")) + + options.ignoredErrorsForDeduplication!!.clear() + + assertThat(options.ignoredErrorsForDeduplication).containsExactly("example.FirstException") + } + + @Test + fun `ignored errors for deduplication getters return null when unset`() { + val options = SentryOptions() + options.setIgnoredErrorsForDeduplication(null) + + assertThat(options.ignoredErrorsForDeduplication).isNull() + assertThat(options.ignoredErrorFiltersForDeduplication).isNull() + } + @Test fun `merging options without ignored errors for deduplication preserves the default`() { val options = SentryOptions() @@ -1083,7 +1105,7 @@ class SentryOptionsTest { options.merge(ExternalOptions()) assertThat(options.ignoredErrorsForDeduplication) - .containsExactly(FilterString("io.netty.handler.timeout.ReadTimeoutException")) + .containsExactly("io.netty.handler.timeout.ReadTimeoutException") } @Test @@ -1097,10 +1119,7 @@ class SentryOptionsTest { options.merge(externalOptions) assertThat(options.ignoredErrorsForDeduplication) - .containsExactly( - FilterString("example.FirstException"), - FilterString("example.SecondException"), - ) + .containsExactly("example.FirstException", "example.SecondException") .inOrder() } From 9ab01e7889426f589eea46af8f3bc33198f8b370 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 6 Nov 2026 16:16:36 +0100 Subject: [PATCH 5/7] fix(core): Exclude Netty write timeouts from deduplication Netty reuses WriteTimeoutException.INSTANCE across independent timeout occurrences. Exclude it by default alongside ReadTimeoutException so later write timeout events are not suppressed for the SDK lifetime. Refs #6365 Co-Authored-By: Claude --- CHANGELOG.md | 2 +- .../android/core/ManifestMetadataReaderTest.kt | 6 +++++- sentry/src/main/java/io/sentry/SentryOptions.java | 9 +++++++-- .../src/test/java/io/sentry/SentryOptionsTest.kt | 14 +++++++++++--- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e4196ce91..cb37bf13576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ ### Fixes -- Preserve independent Netty read timeout events when the exception singleton is reused ([#6465](https://github.com/getsentry/sentry-java/pull/6465)) +- Preserve independent Netty read and write timeout events when exception singletons are reused ([#6465](https://github.com/getsentry/sentry-java/pull/6465)) ### Improvements diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 70cc8cb454d..470b8bded66 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -2110,7 +2110,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) assertThat(fixture.options.ignoredErrorsForDeduplication) - .containsExactly("io.netty.handler.timeout.ReadTimeoutException") + .containsExactly( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() } @Test diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 34371e3622e..d7098d00611 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -40,6 +40,7 @@ import io.sentry.util.thread.NoOpThreadChecker; import java.io.File; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -78,8 +79,10 @@ public class SentryOptions implements RateLimiterConfig { */ private static final String DEFAULT_ENVIRONMENT = "production"; - private static final String DEFAULT_IGNORED_ERROR_FOR_DEDUPLICATION = + private static final String DEFAULT_IGNORED_READ_ERROR_FOR_DEDUPLICATION = "io.netty.handler.timeout.ReadTimeoutException"; + private static final String DEFAULT_IGNORED_WRITE_ERROR_FOR_DEDUPLICATION = + "io.netty.handler.timeout.WriteTimeoutException"; /** * Are callbacks that run for every event. They can either return a new event which in most cases @@ -393,7 +396,9 @@ public class SentryOptions implements RateLimiterConfig { /** Exception types that are excluded from event deduplication. */ private @Nullable List ignoredErrorsForDeduplication = new ArrayList<>( - Collections.singletonList(new FilterString(DEFAULT_IGNORED_ERROR_FOR_DEDUPLICATION))); + Arrays.asList( + new FilterString(DEFAULT_IGNORED_READ_ERROR_FOR_DEDUPLICATION), + new FilterString(DEFAULT_IGNORED_WRITE_ERROR_FOR_DEDUPLICATION))); /** * Enables event size limiting with {@link EventSizeLimitingEventProcessor}. When enabled, events diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index ecd0a0f2d59..be4fd499c09 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1056,9 +1056,13 @@ class SentryOptionsTest { } @Test - fun `ignored errors for deduplication contains Netty read timeout by default`() { + fun `ignored errors for deduplication contains Netty timeout exceptions by default`() { assertThat(SentryOptions().ignoredErrorsForDeduplication) - .containsExactly("io.netty.handler.timeout.ReadTimeoutException") + .containsExactly( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() } @Test @@ -1105,7 +1109,11 @@ class SentryOptionsTest { options.merge(ExternalOptions()) assertThat(options.ignoredErrorsForDeduplication) - .containsExactly("io.netty.handler.timeout.ReadTimeoutException") + .containsExactly( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() } @Test From b98e834de67c158245a465484384e79d2ae60908 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 6 Nov 2026 16:27:05 +0100 Subject: [PATCH 6/7] fix(core): Bypass deduplication for ignored errors Return ignored top-level exceptions before checking their causes so configured exclusions consistently preserve the event. Refs #6365 Co-Authored-By: Claude --- .../DuplicateEventDetectionEventProcessor.java | 10 +++++----- .../DuplicateEventDetectionEventProcessorTest.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java index 8013ec98c88..aa83e7f5639 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -26,9 +26,10 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option if (options.isEnableDeduplication()) { final Throwable throwable = event.getThrowable(); if (throwable != null) { - final boolean ignoredForDeduplication = isIgnoredForDeduplication(throwable); - if ((!ignoredForDeduplication && capturedObjects.containsKey(throwable)) - || containsAnyKey(allCauses(throwable))) { + if (isIgnoredForDeduplication(throwable)) { + return event; + } + if (capturedObjects.containsKey(throwable) || containsAnyKey(allCauses(throwable))) { options .getLogger() .log( @@ -36,9 +37,8 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option "Duplicate Exception detected. Event %s will be discarded.", event.getEventId()); return null; - } else if (!ignoredForDeduplication) { - capturedObjects.put(throwable, null); } + capturedObjects.put(throwable, null); } } else { options.getLogger().log(SentryLevel.DEBUG, "Event deduplication is disabled."); diff --git a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt index 5f62234d06f..8b1568127bd 100644 --- a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt @@ -87,6 +87,20 @@ class DuplicateEventDetectionEventProcessorTest { assertThat(processor.process(event, Hint())).isNotNull() } + @Test + fun `ignored top-level exception bypasses cause deduplication`() { + val options = + SentryOptions().apply { + setIgnoredErrorsForDeduplication(listOf(IgnoredForDeduplicationException::class.java.name)) + } + val processor = DuplicateEventDetectionEventProcessor(options) + val cause = RuntimeException() + assertThat(processor.process(SentryEvent(cause), Hint())).isNotNull() + + val ignoredWrapper = IgnoredForDeduplicationException(cause) + assertThat(processor.process(SentryEvent(ignoredWrapper), Hint())).isNotNull() + } + @Test fun `does not check ignored cause against captured exceptions`() { val options = SentryOptions().apply { setIgnoredErrorsForDeduplication(emptyList()) } From 6866060bcf2487a65777dcb1d40f0c4aacf114fd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 6 Nov 2026 16:48:08 +0100 Subject: [PATCH 7/7] fix(core): Stop deduplication at ignored causes Treat ignored exceptions as cause-chain boundaries so deeper causes do not discard an event while non-ignored wrappers remain deduplicatable. Refs #6365 Co-Authored-By: Claude --- .../DuplicateEventDetectionEventProcessor.java | 5 ++++- .../DuplicateEventDetectionEventProcessorTest.kt | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java index aa83e7f5639..e507745447b 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -48,7 +48,10 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option private boolean containsAnyKey(final @NotNull List throwables) { for (final Throwable throwable : throwables) { - if (!isIgnoredForDeduplication(throwable) && capturedObjects.containsKey(throwable)) { + if (isIgnoredForDeduplication(throwable)) { + return false; + } + if (capturedObjects.containsKey(throwable)) { return true; } } diff --git a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt index 8b1568127bd..23755678c4f 100644 --- a/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt @@ -113,6 +113,21 @@ class DuplicateEventDetectionEventProcessorTest { assertThat(processor.process(SentryEvent(RuntimeException(cause)), Hint())).isNotNull() } + @Test + fun `stops checking causes after ignored cause`() { + val options = + SentryOptions().apply { + setIgnoredErrorsForDeduplication(listOf(IgnoredForDeduplicationException::class.java.name)) + } + val processor = DuplicateEventDetectionEventProcessor(options) + val deeperCause = RuntimeException() + assertThat(processor.process(SentryEvent(deeperCause), Hint())).isNotNull() + + val event = SentryEvent(RuntimeException(IgnoredForDeduplicationException(deeperCause))) + assertThat(processor.process(event, Hint())).isNotNull() + assertThat(processor.process(event, Hint())).isNull() + } + @Test fun `ignored exception patterns are matched against the fully qualified class name`() { val options =