diff --git a/CHANGELOG.md b/CHANGELOG.md index 686f2d778b..cb37bf1357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ - App starts are measured from process start on Android 7.0+ (API 24), and slow and frozen frames are measured per span - `ActivityFramesTracker` is removed, and the `ActivityLifecycleIntegration` constructor no longer takes it +### Fixes + +- Preserve independent Netty read and write timeout events when exception singletons are 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)) 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 7695d86f0b..e99d54ed1c 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 @@ -186,6 +186,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"; @@ -731,6 +734,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 d7481c22b2..470b8bded6 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 @@ -2087,6 +2087,46 @@ 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("example.FirstException", "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( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() + } + + @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 7f15669b10..e686a6634f 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 @@ -227,6 +227,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", @@ -280,6 +281,8 @@ class SentryAutoConfigurationTest { assertThat(options.isSendModules).isEqualTo(false) assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") assertThat(options.ignoredErrors).containsOnly("Some error", "Another .*") + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions).containsOnly("transactionName1", "transactionNameB") assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isEnableDatabaseTransactionTracing).isEqualTo(true) @@ -353,6 +356,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 1b46809b3f..f441bab249 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 @@ -230,6 +230,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", @@ -285,6 +286,8 @@ class SentryAutoConfigurationTest { assertThat(options.isSendModules).isEqualTo(false) assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") assertThat(options.ignoredErrors).containsOnly("Some error", "Another .*") + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions).containsOnly("transactionName1", "transactionNameB") assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isEnableDatabaseTransactionTracing).isEqualTo(true) @@ -361,6 +364,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 9b9501246e..8b84666884 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 @@ -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,8 @@ class SentryAutoConfigurationTest { assertThat(options.isSendModules).isEqualTo(false) assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") assertThat(options.ignoredErrors).containsOnly("Some error", "Another .*") + assertThat(options.ignoredErrorsForDeduplication) + .containsOnly("example.FirstException", "example.SecondException") assertThat(options.ignoredTransactions).containsOnly("transactionName1", "transactionNameB") assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isEnableDatabaseTransactionTracing).isEqualTo(true) @@ -359,6 +362,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 f5dba14a31..8c0259d6c9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -566,6 +566,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; @@ -623,6 +624,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 @@ -3495,6 +3497,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 @@ -3554,7 +3557,9 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun getIgnoredCheckInFilters ()Ljava/util/List; public fun getIgnoredCheckIns ()Ljava/util/List; public fun getIgnoredErrorFilters ()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; public fun getIgnoredSpanOriginFilters ()Ljava/util/List; public fun getIgnoredSpanOrigins ()Ljava/util/List; @@ -3729,6 +3734,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 c3f02e0314..e507745447 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -26,8 +26,10 @@ 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))) { + if (isIgnoredForDeduplication(throwable)) { + return event; + } + if (capturedObjects.containsKey(throwable) || containsAnyKey(allCauses(throwable))) { options .getLogger() .log( @@ -35,9 +37,8 @@ public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions option "Duplicate Exception detected. Event %s will be discarded.", event.getEventId()); return null; - } else { - capturedObjects.put(throwable, null); } + capturedObjects.put(throwable, null); } } else { options.getLogger().log(SentryLevel.DEBUG, "Event deduplication is disabled."); @@ -45,10 +46,29 @@ 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)) { + return false; + } + if (capturedObjects.containsKey(throwable)) { + return true; + } + } + return false; + } + + private boolean isIgnoredForDeduplication(final @NotNull Throwable throwable) { + final @Nullable List ignoredErrors = + options.getIgnoredErrorFiltersForDeduplication(); + 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/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index efeb005f7b..168e582379 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 71dd4588e3..d7098d0061 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,6 +79,11 @@ public class SentryOptions implements RateLimiterConfig { */ private static final String DEFAULT_ENVIRONMENT = "production"; + 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 * means just adding data OR return null in case the event will be dropped and not sent. @@ -387,6 +393,13 @@ public class SentryOptions implements RateLimiterConfig { */ private boolean enableDeduplication = true; + /** Exception types that are excluded from event deduplication. */ + private @Nullable List ignoredErrorsForDeduplication = + new ArrayList<>( + 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 * exceeding 1MB will have breadcrumbs and stack frames reduced to stay under the limit. @@ -1949,6 +1962,68 @@ public void setEnableDeduplication(final boolean enableDeduplication) { this.enableDeduplication = 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() { + 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; + } + + /** + * 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. * @@ -3827,6 +3902,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/DuplicateEventDetectionEventProcessorTest.kt b/sentry/src/test/java/io/sentry/DuplicateEventDetectionEventProcessorTest.kt index 8fb929224e..23755678c4 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,87 @@ 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 `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()) } + 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 `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 = + 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/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 349058e4b3..06fb29430b 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 2a33e9b53f..be4fd499c0 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("Some error", "Another .*"), options.ignoredErrors, ) + assertEquals( + listOf("example.FirstException", "example.SecondException"), + options.ignoredErrorsForDeduplication, + ) assertFalse(options.isEnableBackpressureHandling) assertTrue(options.isEnableDatabaseTransactionTracing) assertTrue(options.isEnableCacheTracing) @@ -1049,6 +1055,92 @@ class SentryOptionsTest { assertEquals(SentryOptions().initPriority, InitPriority.MEDIUM) } + @Test + fun `ignored errors for deduplication contains Netty timeout exceptions by default`() { + assertThat(SentryOptions().ignoredErrorsForDeduplication) + .containsExactly( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() + } + + @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("example.FirstException", "example.SecondException") + .inOrder() + assertThat(options.ignoredErrorFiltersForDeduplication) + .containsExactly( + FilterString("example.FirstException"), + FilterString("example.SecondException"), + ) + .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() + + options.merge(ExternalOptions()) + + assertThat(options.ignoredErrorsForDeduplication) + .containsExactly( + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException", + ) + .inOrder() + } + + @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("example.FirstException", "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 `ignored filter getters return the strings that were set`() { val options = SentryOptions()