diff --git a/CHANGELOG.md b/CHANGELOG.md index ee444a8d3da..21b842ed7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Added experimental scope-based duplicate event detection for reusable exception instances ([#6431](https://github.com/getsentry/sentry-java/pull/6431)) + - Enable it with `enable-deduplication=false` and `enable-scope-based-deduplication=true`, or the equivalent `SentryOptions` setters. + - Deduplication state is isolated per isolation scope, so request integrations that fork scopes no longer suppress later events when one exception instance is reused. - Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment 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 7de5a0c2716..9c71007e30c 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 @@ -141,6 +141,9 @@ final class ManifestMetadataReader { static final String ENABLE_SENTRY = "io.sentry.enabled"; + static final String ENABLE_SCOPE_BASED_DEDUPLICATION = + "io.sentry.enable-scope-based-deduplication"; + static final String SEND_MODULES = "io.sentry.send-modules"; static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; @@ -357,6 +360,12 @@ static void applyMetadata( options.setEnabled(enabled); options.setDsn(dsn); + options.setEnableScopeBasedDeduplication( + readBool( + metadata, + logger, + ENABLE_SCOPE_BASED_DEDUPLICATION, + options.isEnableScopeBasedDeduplication())); options.setEnableNdk(readBool(metadata, logger, NDK_ENABLE, options.isEnableNdk())); 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 463b5d82ddb..6811415fefe 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 @@ -84,6 +84,40 @@ class ManifestMetadataReaderTest { verifyNoInteractions(context) } + @Test + fun `applyMetadata enables scope-based deduplication from manifest`() { + val context = + fixture.getContext(bundleOf(ManifestMetadataReader.ENABLE_SCOPE_BASED_DEDUPLICATION to true)) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.isEnableScopeBasedDeduplication).isTrue() + } + + @Test + fun `applyMetadata disables scope-based deduplication from manifest`() { + fixture.options.isEnableScopeBasedDeduplication = true + val context = + fixture.getContext(bundleOf(ManifestMetadataReader.ENABLE_SCOPE_BASED_DEDUPLICATION to false)) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.isEnableScopeBasedDeduplication).isFalse() + } + + @Test + fun `applyMetadata preserves scope-based deduplication when manifest value is absent`() { + fixture.options.isEnableScopeBasedDeduplication = true + + ManifestMetadataReader.applyMetadata( + fixture.getContext(), + fixture.options, + fixture.buildInfoProvider, + ) + + assertThat(fixture.options.isEnableScopeBasedDeduplication).isTrue() + } + @Test fun `isAutoInit reads build-time metadata without querying context`() { val context = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 725a5a61122..cf1df308231 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -213,6 +213,30 @@ class SentryAndroidTest { assertTrue(Sentry.isEnabled()) } + @Test + fun `manifest scope-based deduplication is applied before processor installation`() { + ContextUtils.resetInstance() + val metadata = + Bundle().apply { + putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") + putBoolean(ManifestMetadataReader.ENABLE_SCOPE_BASED_DEDUPLICATION, true) + } + + fixture.initSut(ContextUtilsTestHelper.mockMetaData(metaData = metadata)) { + it.isEnableDeduplication = false + } + + val scopes = Sentry.getCurrentScopes() + assertFalse(scopes.options.isEnableDeduplication) + assertTrue(scopes.options.isEnableScopeBasedDeduplication) + assertEquals( + 1, + scopes.isolationScope.eventProcessors.count { + it.javaClass.simpleName == "ScopeBasedDuplicateEventDetectionEventProcessor" + }, + ) + } + @Test fun `init won't throw exception`() { val logger = mock() diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt index ab160faac40..4462ffde7f4 100644 --- a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt @@ -28,6 +28,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.slf4j.MDC @@ -40,6 +41,8 @@ class SentryHandlerTest { val transport: ITransport = mock(), contextTags: List? = null, printfStyle: Boolean? = null, + enableDeduplication: Boolean = true, + enableScopeBasedDeduplication: Boolean = false, ) { var logger: Logger var handler: SentryHandler @@ -48,6 +51,8 @@ class SentryHandlerTest { val options = SentryOptions() options.dsn = "http://key@localhost/proj" options.setTransportFactory { _, _ -> transport } + options.isEnableDeduplication = enableDeduplication + options.isEnableScopeBasedDeduplication = enableScopeBasedDeduplication options.logs.loggerBatchProcessorFactory = ILoggerBatchProcessorFactory { options, client -> LoggerBatchProcessor(options, client, ImmediateExecutorService()) } @@ -478,6 +483,28 @@ class SentryHandlerTest { .send(checkLogs { event -> assertEquals(SentryLogLevel.WARN, event.items.first().level) }) } + @Test + fun `scope-based deduplication sends repeated reusable exceptions from JUL`() { + fixture = + Fixture( + minimumEventLevel = Level.SEVERE, + enableDeduplication = false, + enableScopeBasedDeduplication = true, + ) + val throwable = RuntimeException("reused") + + fixture.logger.log(Level.SEVERE, "first", throwable) + fixture.logger.log(Level.SEVERE, "second", throwable) + + verify(fixture.transport, times(2)) + .send( + checkEvent { event -> + assertEquals(SentryHandler.MECHANISM_TYPE, event.exceptions!!.first().mechanism!!.type) + }, + anyOrNull(), + ) + } + @Test fun `converts severe log level to Sentry log level`() { fixture = Fixture(minimumLevel = Level.SEVERE) diff --git a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt index c32459ea022..fc4d64cba66 100644 --- a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt +++ b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt @@ -36,6 +36,7 @@ import org.apache.logging.log4j.spi.ExtendedLogger import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -57,6 +58,8 @@ class SentryAppenderTest { minimumLevel: Level? = null, debug: Boolean? = null, contextTags: List? = null, + enableDeduplication: Boolean = true, + enableScopeBasedDeduplication: Boolean = false, ): ExtendedLogger { if (transportFactory != null) { this.transportFactory = transportFactory @@ -98,6 +101,8 @@ class SentryAppenderTest { appender.start( appender.getOptionsConfiguration { options -> + options.isEnableDeduplication = enableDeduplication + options.isEnableScopeBasedDeduplication = enableScopeBasedDeduplication options.logs.loggerBatchProcessorFactory = ILoggerBatchProcessorFactory { options, client -> LoggerBatchProcessor(options, client, ImmediateExecutorService()) @@ -162,6 +167,28 @@ class SentryAppenderTest { ) } + @Test + fun `scope-based deduplication sends repeated reusable exceptions from Log4j2`() { + val logger = + fixture.getSut( + minimumEventLevel = Level.ERROR, + enableDeduplication = false, + enableScopeBasedDeduplication = true, + ) + val throwable = RuntimeException("reused") + + logger.error("first", throwable) + logger.error("second", throwable) + + verify(fixture.transport, times(2)) + .send( + checkEvent { event -> + assertEquals(SentryAppender.MECHANISM_TYPE, event.exceptions!!.first().mechanism!!.type) + }, + anyOrNull(), + ) + } + @Test fun `event date is in UTC`() { val logger = fixture.getSut(minimumEventLevel = Level.DEBUG) diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index bb10c5ede9c..f53a0d46886 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -39,6 +39,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.slf4j.Logger @@ -410,6 +411,31 @@ class SentryAppenderTest { ) } + @Test + fun `scope-based deduplication sends repeated reusable exceptions from Logback`() { + fixture = + Fixture( + minimumEventLevel = Level.ERROR, + options = + SentryOptions().apply { + isEnableDeduplication = false + isEnableScopeBasedDeduplication = true + }, + ) + val throwable = RuntimeException("reused") + + fixture.logger.error("first", throwable) + fixture.logger.error("second", throwable) + + verify(fixture.transport, times(2)) + .send( + checkEvent { event -> + assertEquals(SentryAppender.MECHANISM_TYPE, event.exceptions!!.first().mechanism!!.type) + }, + anyOrNull(), + ) + } + @Test fun `converts trace log level to Sentry log level`() { fixture = Fixture(minimumLevel = Level.TRACE, enableLogs = true) 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 c0eb866de02..07da1717868 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 @@ -148,6 +148,25 @@ class SentryAutoConfigurationTest { dsnEnabledRunner.run { assertThat(it).hasSingleBean(IScopes::class.java) } } + @Test + fun `scope-based deduplication property is applied before processor installation`() { + dsnEnabledRunner + .withPropertyValues( + "sentry.enable-deduplication=false", + "sentry.enable-scope-based-deduplication=true", + ) + .run { + val scopes = it.getBean(IScopes::class.java) + assertThat(scopes.options.isEnableScopeBasedDeduplication).isTrue() + assertThat( + scopes.isolationScope.eventProcessors.filter { processor -> + processor.javaClass.simpleName == "ScopeBasedDuplicateEventDetectionEventProcessor" + } + ) + .hasSize(1) + } + } + @Test fun `OptionsConfiguration is created if custom one with name sentryOptionsConfiguration is not provided`() { dsnEnabledRunner.run { assertThat(it).hasSingleBean(Sentry.OptionsConfiguration::class.java) } @@ -204,6 +223,7 @@ class SentryAutoConfigurationTest { "sentry.shutdown-timeout-millis=20", "sentry.flush-timeout-millis=30", "sentry.debug=true", + "sentry.enable-scope-based-deduplication=true", "sentry.diagnostic-level=INFO", "sentry.sentry-client-name=my-client", "sentry.max-breadcrumbs=100", @@ -254,6 +274,7 @@ class SentryAutoConfigurationTest { assertThat(options.shutdownTimeoutMillis).isEqualTo(20) assertThat(options.flushTimeoutMillis).isEqualTo(30) assertThat(options.isDebug).isTrue() + assertThat(options.isEnableScopeBasedDeduplication).isTrue() assertThat(options.diagnosticLevel).isEqualTo(SentryLevel.INFO) assertThat(options.maxBreadcrumbs).isEqualTo(100) assertThat(options.release).isEqualTo("1.0.3") 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 31876882c4d..d3a4f246c4c 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 @@ -151,6 +151,25 @@ class SentryAutoConfigurationTest { dsnEnabledRunner.run { assertThat(it).hasSingleBean(IScopes::class.java) } } + @Test + fun `scope-based deduplication property is applied before processor installation`() { + dsnEnabledRunner + .withPropertyValues( + "sentry.enable-deduplication=false", + "sentry.enable-scope-based-deduplication=true", + ) + .run { + val scopes = it.getBean(IScopes::class.java) + assertThat(scopes.options.isEnableScopeBasedDeduplication).isTrue() + assertThat( + scopes.isolationScope.eventProcessors.filter { processor -> + processor.javaClass.simpleName == "ScopeBasedDuplicateEventDetectionEventProcessor" + } + ) + .hasSize(1) + } + } + @Test fun `OptionsConfiguration is created if custom one with name sentryOptionsConfiguration is not provided`() { dsnEnabledRunner.run { assertThat(it).hasSingleBean(Sentry.OptionsConfiguration::class.java) } @@ -207,6 +226,7 @@ class SentryAutoConfigurationTest { "sentry.shutdown-timeout-millis=20", "sentry.flush-timeout-millis=30", "sentry.debug=true", + "sentry.enable-scope-based-deduplication=true", "sentry.diagnostic-level=INFO", "sentry.sentry-client-name=my-client", "sentry.max-breadcrumbs=100", @@ -259,6 +279,7 @@ class SentryAutoConfigurationTest { assertThat(options.shutdownTimeoutMillis).isEqualTo(20) assertThat(options.flushTimeoutMillis).isEqualTo(30) assertThat(options.isDebug).isTrue() + assertThat(options.isEnableScopeBasedDeduplication).isTrue() assertThat(options.diagnosticLevel).isEqualTo(SentryLevel.INFO) assertThat(options.maxBreadcrumbs).isEqualTo(100) assertThat(options.release).isEqualTo("1.0.3") 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 31be74477fd..5533311da36 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 @@ -150,6 +150,25 @@ class SentryAutoConfigurationTest { dsnEnabledRunner.run { assertThat(it).hasSingleBean(IScopes::class.java) } } + @Test + fun `scope-based deduplication property is applied before processor installation`() { + dsnEnabledRunner + .withPropertyValues( + "sentry.enable-deduplication=false", + "sentry.enable-scope-based-deduplication=true", + ) + .run { + val scopes = it.getBean(IScopes::class.java) + assertThat(scopes.options.isEnableScopeBasedDeduplication).isTrue() + assertThat( + scopes.isolationScope.eventProcessors.filter { processor -> + processor.javaClass.simpleName == "ScopeBasedDuplicateEventDetectionEventProcessor" + } + ) + .hasSize(1) + } + } + @Test fun `OptionsConfiguration is created if custom one with name sentryOptionsConfiguration is not provided`() { dsnEnabledRunner.run { assertThat(it).hasSingleBean(Sentry.OptionsConfiguration::class.java) } @@ -205,6 +224,7 @@ class SentryAutoConfigurationTest { "sentry.shutdown-timeout-millis=20", "sentry.flush-timeout-millis=30", "sentry.debug=true", + "sentry.enable-scope-based-deduplication=true", "sentry.diagnostic-level=INFO", "sentry.sentry-client-name=my-client", "sentry.max-breadcrumbs=100", @@ -257,6 +277,7 @@ class SentryAutoConfigurationTest { assertThat(options.shutdownTimeoutMillis).isEqualTo(20) assertThat(options.flushTimeoutMillis).isEqualTo(30) assertThat(options.isDebug).isTrue() + assertThat(options.isEnableScopeBasedDeduplication).isTrue() assertThat(options.diagnosticLevel).isEqualTo(SentryLevel.INFO) assertThat(options.maxBreadcrumbs).isEqualTo(100) assertThat(options.release).isEqualTo("1.0.3") diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt index 26afd5811bf..6650f04a44b 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt @@ -38,6 +38,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired @@ -222,6 +223,26 @@ class SentrySpringIntegrationTest { } } + @Test + fun `reused exception is sent for separate MVC requests`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + repeat(2) { + restTemplate.getForEntity("http://localhost:$port/throws-reused", String::class.java) + } + + await.untilAsserted { + verify(transport, times(2)) + .send( + checkEvent { event -> + assertThat(event.exceptions!!.first().mechanism!!.type) + .isEqualTo(SentryExceptionResolver.MECHANISM_TYPE) + }, + anyOrNull(), + ) + } + } + @Test fun `attaches transaction name to events`() { val restTemplate = TestRestTemplate().withBasicAuth("user", "password") @@ -405,6 +426,13 @@ open class App { @Bean open fun tracesSamplerCallback() = SentryOptions.TracesSamplerCallback { 1.0 } + @Bean + open fun sentryOptionsConfiguration() = + Sentry.OptionsConfiguration { + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + @Bean open fun springSecuritySentryUserProvider(sentryOptions: SentryOptions) = SpringSecuritySentryUserProvider(sentryOptions) @@ -479,6 +507,9 @@ open class SomeService { @RestController class HelloController(private val webClient: WebClient, private val env: Environment) { + companion object { + private val reusableException = RuntimeException("reused exception") + } @GetMapping("/hello") fun hello(): String { @@ -501,6 +532,11 @@ class HelloController(private val webClient: WebClient, private val env: Environ throw RuntimeException("something went wrong") } + @GetMapping("/throws-reused") + fun throwsReused() { + throw reusableException + } + @GetMapping("/throws-handled") fun throwsHandled() { throw CustomException("handled exception") diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebfluxIntegrationTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebfluxIntegrationTest.kt index f1ace818f6d..11e35fcf8ef 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebfluxIntegrationTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebfluxIntegrationTest.kt @@ -22,6 +22,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.reset +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired @@ -104,6 +105,29 @@ class SentryWebfluxIntegrationTest { ) } + @Test + fun `reused exception is sent for separate WebFlux requests`() { + repeat(2) { + testClient + .get() + .uri("http://localhost:$port/throws-reused") + .exchange() + .expectStatus() + .is5xxServerError + } + + await.untilAsserted { + verify(transport, times(2)) + .send( + checkEvent { event -> + assertThat(event.exceptions!!.last().mechanism!!.type) + .isEqualTo(SentryWebExceptionHandler.MECHANISM_TYPE) + }, + anyOrNull(), + ) + } + } + @Test fun `does not send events for handled exceptions`() { testClient @@ -170,6 +194,8 @@ open class App { it.dsn = "http://key@localhost/proj" it.setDebug(true) it.setTransportFactory(transportFactory) + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true it.tracesSampleRate = 1.0 } } @@ -177,6 +203,10 @@ open class App { @RestController class HelloController { + companion object { + private val reusableException = RuntimeException("reused exception") + } + @GetMapping("/hello") fun hello(): Mono { Sentry.captureMessage("hello") @@ -185,6 +215,8 @@ class HelloController { @GetMapping("/throws") fun throws(): Unit = throw RuntimeException("something went wrong") + @GetMapping("/throws-reused") fun throwsReused(): Unit = throw reusableException + @GetMapping("/throws-handled") fun throwsHandled(): Unit = throw CustomException("handled exception") } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ae96a1abe45..ed340dace69 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -562,6 +562,7 @@ public final class io/sentry/ExternalOptions { public fun getDist ()Ljava/lang/String; public fun getDsn ()Ljava/lang/String; public fun getEnableDeduplication ()Ljava/lang/Boolean; + public fun getEnableScopeBasedDeduplication ()Ljava/lang/Boolean; public fun getEnableUncaughtExceptionHandler ()Ljava/lang/Boolean; public fun getEnvironment ()Ljava/lang/String; public fun getIdleTimeout ()Ljava/lang/Long; @@ -619,6 +620,7 @@ public final class io/sentry/ExternalOptions { public fun setEnableMetrics (Ljava/lang/Boolean;)V public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V public fun setEnableQueueTracing (Ljava/lang/Boolean;)V + public fun setEnableScopeBasedDeduplication (Ljava/lang/Boolean;)V public fun setEnableSpotlight (Ljava/lang/Boolean;)V public fun setEnableUncaughtExceptionHandler (Ljava/lang/Boolean;)V public fun setEnabled (Ljava/lang/Boolean;)V @@ -3909,6 +3911,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun isEnableLegacyProfiling ()Z public fun isEnablePrettySerializationOutput ()Z public fun isEnableQueueTracing ()Z + public fun isEnableScopeBasedDeduplication ()Z public fun isEnableScopePersistence ()Z public fun isEnableScreenTracking ()Z public fun isEnableShutdownHook ()Z @@ -3973,6 +3976,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun setEnableLegacyProfiling (Z)V public fun setEnablePrettySerializationOutput (Z)V public fun setEnableQueueTracing (Z)V + public fun setEnableScopeBasedDeduplication (Z)V public fun setEnableScopePersistence (Z)V public fun setEnableScreenTracking (Z)V public fun setEnableShutdownHook (Z)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 810130416aa..6016af22645 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -23,6 +23,7 @@ public final class ExternalOptions { private @Nullable Boolean enableUncaughtExceptionHandler; private @Nullable Boolean debug; private @Nullable Boolean enableDeduplication; + private @Nullable Boolean enableScopeBasedDeduplication; private @Nullable Double sampleRate; private @Nullable Double tracesSampleRate; private @Nullable Double profilesSampleRate; @@ -91,6 +92,8 @@ public final class ExternalOptions { options.setProfilesSampleRate(propertiesProvider.getDoubleProperty("profiles-sample-rate")); options.setDebug(propertiesProvider.getBooleanProperty("debug")); options.setEnableDeduplication(propertiesProvider.getBooleanProperty("enable-deduplication")); + options.setEnableScopeBasedDeduplication( + propertiesProvider.getBooleanProperty("enable-scope-based-deduplication")); options.setSendClientReports(propertiesProvider.getBooleanProperty("send-client-reports")); options.setForceInit(propertiesProvider.getBooleanProperty("force-init")); final String maxRequestBodySize = propertiesProvider.getProperty("max-request-body-size"); @@ -418,6 +421,15 @@ public void setEnableDeduplication(final @Nullable Boolean enableDeduplication) this.enableDeduplication = enableDeduplication; } + public @Nullable Boolean getEnableScopeBasedDeduplication() { + return enableScopeBasedDeduplication; + } + + public void setEnableScopeBasedDeduplication( + final @Nullable Boolean enableScopeBasedDeduplication) { + this.enableScopeBasedDeduplication = enableScopeBasedDeduplication; + } + public @Nullable Double getSampleRate() { return sampleRate; } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 00f5729535a..01ff92d29d2 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -347,6 +347,17 @@ private static void init(final @NotNull SentryOptions options, final boolean glo final IScope rootScope = new Scope(options); final IScope rootIsolationScope = new Scope(options); + if (options.isEnableScopeBasedDeduplication()) { + rootIsolationScope.addEventProcessor( + new ScopeBasedDuplicateEventDetectionEventProcessor(options)); + if (options.isEnableDeduplication()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Scope-based and legacy event deduplication are both enabled. Legacy deduplication can still suppress reused throwable instances."); + } + } rootScopes = new Scopes(rootScope, rootIsolationScope, globalScope, "Sentry.init"); initLogger(options); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f4a17e3ebe5..ed26e7054a8 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -387,6 +387,12 @@ public class SentryOptions implements RateLimiterConfig { */ private boolean enableDeduplication = true; + /** + * Enables scope-based event deduplication with {@link + * ScopeBasedDuplicateEventDetectionEventProcessor}. + */ + private boolean enableScopeBasedDeduplication = false; + /** * Enables event size limiting with {@link EventSizeLimitingEventProcessor}. When enabled, events * exceeding 1MB will have breadcrumbs and stack frames reduced to stay under the limit. @@ -1951,6 +1957,40 @@ public void setEnableDeduplication(final boolean enableDeduplication) { this.enableDeduplication = enableDeduplication; } + /** + * Returns whether experimental scope-based event deduplication is enabled. + * + *

This option defaults to {@code false}. It is read during SDK initialization to decide + * whether to install the processor on the root isolation scope. Changing it after initialization + * has no effect until the SDK is reinitialized. + * + *

This option is independent from {@link #isEnableDeduplication()}. To use the scope-based + * behavior for reusable exception instances, disable legacy deduplication with {@link + * #setEnableDeduplication(boolean)}. If both options are enabled, both processors run and legacy + * deduplication can still suppress reused throwable instances. + * + * @return {@code true} if scope-based event deduplication is enabled + */ + @ApiStatus.Experimental + public boolean isEnableScopeBasedDeduplication() { + return enableScopeBasedDeduplication; + } + + /** + * Enables or disables experimental scope-based event deduplication. + * + *

The value is read during SDK initialization. The installed processor does not read this + * mutable option afterward, so changing it requires SDK reinitialization. This option does not + * disable {@link #isEnableDeduplication()} automatically; set legacy deduplication to {@code + * false} to use only the scope-based behavior. + * + * @param enableScopeBasedDeduplication {@code true} to enable scope-based event deduplication + */ + @ApiStatus.Experimental + public void setEnableScopeBasedDeduplication(final boolean enableScopeBasedDeduplication) { + this.enableScopeBasedDeduplication = enableScopeBasedDeduplication; + } + /** * Returns if event size limiting is enabled. * @@ -3725,6 +3765,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.getEnableDeduplication() != null) { setEnableDeduplication(options.getEnableDeduplication()); } + if (options.getEnableScopeBasedDeduplication() != null) { + setEnableScopeBasedDeduplication(options.getEnableScopeBasedDeduplication()); + } if (options.getSendClientReports() != null) { setSendClientReports(options.getSendClientReports()); } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index cdd184b181f..e684c00abeb 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -221,6 +221,25 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with scope-based deduplication enabled using external properties`() { + withPropertiesFile("enable-scope-based-deduplication=true") { + assertThat(it.enableScopeBasedDeduplication).isTrue() + } + } + + @Test + fun `creates options with scope-based deduplication disabled using external properties`() { + withPropertiesFile("enable-scope-based-deduplication=false") { + assertThat(it.enableScopeBasedDeduplication).isFalse() + } + } + + @Test + fun `creates options without scope-based deduplication using external properties`() { + withPropertiesFile { assertThat(it.enableScopeBasedDeduplication).isNull() } + } + @Test fun `creates options with sendClientReports using external properties`() { withPropertiesFile("send-client-reports=false") { diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 284c0a98cd5..e8860ed3499 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -217,6 +217,22 @@ class SentryOptionsTest { assertFalse(SentryOptions().isDebug) } + @Test + fun `scope-based deduplication defaults to disabled`() { + assertThat(SentryOptions().isEnableScopeBasedDeduplication).isFalse() + } + + @Test + fun `scope-based deduplication can be enabled and disabled`() { + val options = SentryOptions() + + options.isEnableScopeBasedDeduplication = true + assertThat(options.isEnableScopeBasedDeduplication).isTrue() + + options.isEnableScopeBasedDeduplication = false + assertThat(options.isEnableScopeBasedDeduplication).isFalse() + } + @Test fun `when options is initialized, integrations contain UncaughtExceptionHandlerIntegration`() { assertTrue(SentryOptions().integrations.any { it is UncaughtExceptionHandlerIntegration }) @@ -544,6 +560,7 @@ class SentryOptionsTest { externalOptions.setTag("tag1", "value1") externalOptions.setTag("tag2", "value2") externalOptions.enableUncaughtExceptionHandler = false + externalOptions.enableScopeBasedDeduplication = true externalOptions.sampleRate = 0.3 externalOptions.tracesSampleRate = 0.5 externalOptions.profilesSampleRate = 0.5 @@ -605,6 +622,7 @@ class SentryOptionsTest { assertEquals(java.net.Proxy.Type.SOCKS, options.proxy!!.type) assertEquals(mapOf("tag1" to "value1", "tag2" to "value2"), options.tags) assertFalse(options.isEnableUncaughtExceptionHandler) + assertTrue(options.isEnableScopeBasedDeduplication) assertEquals(0.3, options.sampleRate) assertEquals(0.5, options.tracesSampleRate) assertEquals(0.5, options.profilesSampleRate) @@ -662,6 +680,26 @@ class SentryOptionsTest { assertTrue(options.isEnableUncaughtExceptionHandler) } + @Test + fun `merging options applies explicit scope-based deduplication values`() { + val options = SentryOptions() + + options.merge(ExternalOptions().apply { enableScopeBasedDeduplication = true }) + assertThat(options.isEnableScopeBasedDeduplication).isTrue() + + options.merge(ExternalOptions().apply { enableScopeBasedDeduplication = false }) + assertThat(options.isEnableScopeBasedDeduplication).isFalse() + } + + @Test + fun `merging options without scope-based deduplication preserves the current value`() { + val options = SentryOptions().apply { isEnableScopeBasedDeduplication = true } + + options.merge(ExternalOptions()) + + assertThat(options.isEnableScopeBasedDeduplication).isTrue() + } + @Test fun `merging options when enableMetrics is not set preserves the default value`() { val externalOptions = ExternalOptions() diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 0ccfb79d699..cf4b801d9fa 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -9,12 +9,14 @@ import io.sentry.backpressure.NoOpBackpressureMonitor import io.sentry.cache.EnvelopeCache import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingScopeObserver +import io.sentry.exception.ExceptionMechanismException import io.sentry.internal.debugmeta.IDebugMetaLoader import io.sentry.internal.debugmeta.ResourcesDebugMetaLoader import io.sentry.internal.modules.CompositeModulesLoader import io.sentry.internal.modules.IModulesLoader import io.sentry.internal.modules.NoOpModulesLoader import io.sentry.protocol.Feedback +import io.sentry.protocol.Mechanism import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.protocol.SentryThread @@ -57,16 +59,23 @@ import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.check +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class SentryTest { private val dsn = "http://key@localhost/proj" + companion object { + private const val SCOPE_BASED_DEDUPLICATION_CONFLICT_WARNING = + "Scope-based and legacy event deduplication are both enabled. Legacy deduplication can still suppress reused throwable instances." + } + @get:Rule val tmpDir = TemporaryFolder() @BeforeTest @@ -127,6 +136,202 @@ class SentryTest { verify(integration2).close() } + @Test + fun `scope-based deduplication is inactive by default`() { + initForTest { it.dsn = dsn } + + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .isEmpty() + } + + @Test + fun `scope-based deduplication is installed only on root isolation scope`() { + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + it.defaultScopeType = ScopeType.CURRENT + } + + val scopes = Sentry.getCurrentScopes() + assertThat(scopes.isolationScope.scopeBasedDeduplicationProcessors()).hasSize(1) + assertThat(scopes.scope.scopeBasedDeduplicationProcessors()).isEmpty() + assertThat(scopes.globalScope.scopeBasedDeduplicationProcessors()).isEmpty() + } + + @Test + fun `isolation scope clone receives a fresh scope-based deduplication processor`() { + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val rootScopes = Sentry.getCurrentScopes() + val rootProcessor = rootScopes.isolationScope.scopeBasedDeduplicationProcessors().single() + val throwable = RuntimeException() + assertThat(rootProcessor.process(mechanismEvent(throwable, "LogbackSentryAppender"), Hint())) + .isNotNull() + + val forkedScopes = rootScopes.forkedScopes("test") + val forkedProcessor = forkedScopes.isolationScope.scopeBasedDeduplicationProcessors().single() + + assertThat(forkedProcessor).isNotSameInstanceAs(rootProcessor) + assertThat( + forkedProcessor.process(mechanismEvent(throwable, "Spring6ExceptionResolver"), Hint()) + ) + .isNotNull() + } + + @Test + fun `current-scope fork shares scope-based deduplication processor`() { + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val rootScopes = Sentry.getCurrentScopes() + + val forkedScopes = rootScopes.forkedCurrentScope("test") + + assertSame(rootScopes.isolationScope, forkedScopes.isolationScope) + assertSame( + rootScopes.isolationScope.scopeBasedDeduplicationProcessors().single(), + forkedScopes.isolationScope.scopeBasedDeduplicationProcessors().single(), + ) + } + + @Test + fun `nested isolation scope clones receive fresh processors`() { + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val rootScopes = Sentry.getCurrentScopes() + + val firstFork = rootScopes.forkedScopes("first") + val secondFork = firstFork.forkedScopes("second") + + assertNotSame( + rootScopes.isolationScope.scopeBasedDeduplicationProcessors().single(), + firstFork.isolationScope.scopeBasedDeduplicationProcessors().single(), + ) + assertNotSame( + firstFork.isolationScope.scopeBasedDeduplicationProcessors().single(), + secondFork.isolationScope.scopeBasedDeduplicationProcessors().single(), + ) + } + + @Test + fun `separate isolation scopes cannot suppress each other`() { + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val rootScopes = Sentry.getCurrentScopes() + val firstProcessor = + rootScopes.forkedScopes("first").isolationScope.scopeBasedDeduplicationProcessors().single() + val secondProcessor = + rootScopes.forkedScopes("second").isolationScope.scopeBasedDeduplicationProcessors().single() + val throwable = RuntimeException() + + assertThat(firstProcessor.process(mechanismEvent(throwable, "LogbackSentryAppender"), Hint())) + .isNotNull() + assertThat( + secondProcessor.process(mechanismEvent(throwable, "Spring6ExceptionResolver"), Hint()) + ) + .isNotNull() + } + + @Test + fun `reinitialization creates a fresh scope-based deduplication processor`() { + val throwable = RuntimeException() + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val firstProcessor = + Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors().single() + firstProcessor.process(mechanismEvent(throwable, "LogbackSentryAppender"), Hint()) + + initForTest { + it.dsn = dsn + it.isEnableDeduplication = false + it.isEnableScopeBasedDeduplication = true + } + val secondProcessor = + Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors().single() + + assertThat(secondProcessor).isNotSameInstanceAs(firstProcessor) + assertThat( + secondProcessor.process(mechanismEvent(throwable, "Spring6ExceptionResolver"), Hint()) + ) + .isNotNull() + } + + @Test + fun `changing scope-based deduplication after init requires reinitialization`() { + val options = + SentryOptions().apply { + dsn = this@SentryTest.dsn + isEnableDeduplication = false + } + initForTest(options) + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .isEmpty() + + options.isEnableScopeBasedDeduplication = true + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .isEmpty() + + initForTest(options) + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .hasSize(1) + } + + @Test + fun `both deduplication options remain active and emit one warning`() { + val logger = mock() + initForTest { + it.dsn = dsn + it.isDebug = true + it.setLogger(logger) + it.isEnableDeduplication = true + it.isEnableScopeBasedDeduplication = true + } + + val options = Sentry.getCurrentScopes().options + assertThat(options.isEnableDeduplication).isTrue() + assertThat(options.eventProcessors.filterIsInstance()) + .hasSize(1) + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .hasSize(1) + verify(logger, times(1)) + .log(eq(SentryLevel.WARNING), eq(SCOPE_BASED_DEDUPLICATION_CONFLICT_WARNING)) + } + + @Test + fun `other deduplication option combinations do not emit conflict warning`() { + val logger = mock() + val combinations = listOf(false to false, false to true, true to false) + + combinations.forEach { (scopeBased, legacy) -> + initForTest { + it.dsn = dsn + it.isDebug = true + it.setLogger(logger) + it.isEnableScopeBasedDeduplication = scopeBased + it.isEnableDeduplication = legacy + } + + verify(logger, never()) + .log(eq(SentryLevel.WARNING), eq(SCOPE_BASED_DEDUPLICATION_CONFLICT_WARNING)) + clearInvocations(logger) + } + } + @Test fun `if a single integration crashes, the SDK and other integrations are still initialized`() { val goodIntegrationInitialized = AtomicBoolean(false) @@ -345,7 +550,14 @@ class SentryTest { val temporaryFolder = TemporaryFolder() temporaryFolder.create() val file = temporaryFolder.newFile("sentry.properties") - file.writeText("dsn=http://key@localhost/proj") + file.writeText( + """ + dsn=http://key@localhost/proj + enable-deduplication=false + enable-scope-based-deduplication=true + """ + .trimIndent() + ) // set location of the sentry.properties file System.setProperty("sentry.properties.file", file.absolutePath) @@ -353,7 +565,11 @@ class SentryTest { // initialize Sentry with empty DSN and enable loading properties from external sources initForTest { it.isEnableExternalConfiguration = true } assertTrue(ScopesAdapter.getInstance().isEnabled) + assertThat(Sentry.getCurrentScopes().options.isEnableDeduplication).isFalse() + assertThat(Sentry.getCurrentScopes().isolationScope.scopeBasedDeduplicationProcessors()) + .hasSize(1) } finally { + System.clearProperty("sentry.properties.file") temporaryFolder.delete() } } @@ -1863,6 +2079,14 @@ class SentryTest { override fun discard(envelope: SentryEnvelope) = Unit } + private fun IScope.scopeBasedDeduplicationProcessors() = + eventProcessors.filterIsInstance() + + private fun mechanismEvent(throwable: Throwable, type: String): SentryEvent { + val mechanism = Mechanism().apply { this.type = type } + return SentryEvent(ExceptionMechanismException(mechanism, throwable, Thread.currentThread())) + } + private fun getTempPath(): String { val tempFile = Files.createTempDirectory("cache").toFile() tempFile.delete()