From 49ca70f46233c0abb9f605138c6c782c6db9af7b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 28 Oct 2026 14:01:04 +0100 Subject: [PATCH 1/2] feat(core): Add scope-based deduplication option Expose the experimental mode through core, external, Android, and Spring configuration. Install its processor only on the root isolation scope and warn when legacy deduplication remains enabled. Cover reusable exceptions across logging and Spring request integrations so repeated singleton failures remain visible across isolation scopes. Refs GH-6365 Co-Authored-By: Claude --- CHANGELOG.md | 3 + .../android/core/ManifestMetadataReader.java | 9 + .../core/ManifestMetadataReaderTest.kt | 34 +++ .../sentry/android/core/SentryAndroidTest.kt | 24 ++ .../kotlin/io/sentry/jul/SentryHandlerTest.kt | 27 +++ .../io/sentry/log4j2/SentryAppenderTest.kt | 27 +++ .../io/sentry/logback/SentryAppenderTest.kt | 26 ++ .../boot4/SentryAutoConfigurationTest.kt | 21 ++ .../jakarta/SentryAutoConfigurationTest.kt | 21 ++ .../boot/SentryAutoConfigurationTest.kt | 21 ++ .../spring/mvc/SentrySpringIntegrationTest.kt | 36 +++ .../webflux/SentryWebfluxIntegrationTest.kt | 32 +++ sentry/api/sentry.api | 4 + .../main/java/io/sentry/ExternalOptions.java | 12 + sentry/src/main/java/io/sentry/Sentry.java | 11 + .../main/java/io/sentry/SentryOptions.java | 43 ++++ .../java/io/sentry/ExternalOptionsTest.kt | 19 ++ .../test/java/io/sentry/SentryOptionsTest.kt | 38 +++ sentry/src/test/java/io/sentry/SentryTest.kt | 226 +++++++++++++++++- 19 files changed, 633 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee444a8d3da..b817674cbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Added experimental scope-based duplicate event detection for reusable exception instances ([#6365](https://github.com/getsentry/sentry-java/issues/6365)) + - 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() From 609b5e7ae41325b29a40793f3d9f1562c355a43a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 30 Oct 2026 10:36:58 +0100 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b817674cbba..21b842ed7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added experimental scope-based duplicate event detection for reusable exception instances ([#6365](https://github.com/getsentry/sentry-java/issues/6365)) +- 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))