diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b861a977..edcc705fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Features - Support multiple debug-metadata.properties ([#3024](https://github.com/getsentry/sentry-java/pull/3024)) +- Automatically downsample transactions when the system is under load ([#3072](https://github.com/getsentry/sentry-java/pull/3072)) + - You can opt into this behaviour by setting `enable-backpressure-handling=true`. + - We're happy to receive feedback, e.g. [in this GitHub issue](https://github.com/getsentry/sentry-java/issues/2829) + - When the system is under load we start reducing the `tracesSampleRate` automatically. + - Once the system goes back to healthy, we reset the `tracesSampleRate` to its original value. - (Android) Experimental: Provide more detailed cold app start information ([#3057](https://github.com/getsentry/sentry-java/pull/3057)) - Attaches spans for Application, ContentProvider, and Activities to app-start timings - Uses Process.startUptimeMillis to calculate app-start timings diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index d677399f7e..040a9bb208 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -13,6 +13,7 @@ sentry.enable-tracing=true sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 sentry.debug=true sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true in-app-includes="io.sentry.samples" # Uncomment and set to true to enable aot compatibility diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties index cded2b5e60..007d8d8ef3 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties @@ -9,3 +9,4 @@ sentry.logging.minimum-event-level=info sentry.logging.minimum-breadcrumb-level=debug sentry.reactive.thread-local-accessor-enabled=true sentry.enable-tracing=true +sentry.enable-backpressure-handling=true diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties index a08a498bf2..3b6d041bda 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/resources/application.properties @@ -11,3 +11,4 @@ sentry.enable-tracing=true spring.graphql.graphiql.enabled=true spring.graphql.websocket.path=/graphql spring.graphql.schema.printer.enabled=true +sentry.enable-backpressure-handling=true diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index 75461046db..f63478d9a0 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -13,6 +13,7 @@ sentry.enable-tracing=true sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 sentry.debug=true sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true in-app-includes="io.sentry.samples" # Database configuration 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 8e5221203f..2c1e118ebf 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 @@ -162,7 +162,8 @@ class SentryAutoConfigurationTest { "sentry.trace-propagation-targets=localhost,^(http|https)://api\\..*\$", "sentry.enabled=false", "sentry.send-modules=false", - "sentry.ignored-checkins=slug1,slugB" + "sentry.ignored-checkins=slug1,slugB", + "sentry.enable-backpressure-handling=true" ).run { val options = it.getBean(SentryProperties::class.java) assertThat(options.readTimeoutMillis).isEqualTo(10) @@ -194,6 +195,7 @@ class SentryAutoConfigurationTest { assertThat(options.isEnabled).isEqualTo(false) assertThat(options.isSendModules).isEqualTo(false) assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") + assertThat(options.isEnableBackpressureHandling).isEqualTo(true) } } diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/it/SentrySpringIntegrationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/it/SentrySpringIntegrationTest.kt index 3d7fb26993..4b8a0c26be 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/it/SentrySpringIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/it/SentrySpringIntegrationTest.kt @@ -210,7 +210,9 @@ class SentrySpringIntegrationTest { @SpringBootApplication open class App { - private val transport = mock() + private val transport = mock().also { + whenever(it.isHealthy).thenReturn(true) + } @Bean open fun mockTransportFactory(): ITransportFactory { 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 71369d6f2d..fdf135f99d 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 @@ -162,7 +162,8 @@ class SentryAutoConfigurationTest { "sentry.trace-propagation-targets=localhost,^(http|https)://api\\..*\$", "sentry.enabled=false", "sentry.send-modules=false", - "sentry.ignored-checkins=slug1,slugB" + "sentry.ignored-checkins=slug1,slugB", + "sentry.enable-backpressure-handling=true" ).run { val options = it.getBean(SentryProperties::class.java) assertThat(options.readTimeoutMillis).isEqualTo(10) @@ -194,6 +195,7 @@ class SentryAutoConfigurationTest { assertThat(options.isEnabled).isEqualTo(false) assertThat(options.isSendModules).isEqualTo(false) assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") + assertThat(options.isEnableBackpressureHandling).isEqualTo(true) } } diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/it/SentrySpringIntegrationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/it/SentrySpringIntegrationTest.kt index 5cb1dc9d72..eb6d159a7c 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/it/SentrySpringIntegrationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/it/SentrySpringIntegrationTest.kt @@ -210,7 +210,9 @@ class SentrySpringIntegrationTest { @SpringBootApplication open class App { - private val transport = mock() + private val transport = mock().also { + whenever(it.isHealthy).thenReturn(true) + } @Bean open fun mockTransportFactory(): ITransportFactory { diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/EnableSentryTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/EnableSentryTest.kt index 03e8ad1f99..7b51bbc1e7 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/EnableSentryTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/EnableSentryTest.kt @@ -6,8 +6,11 @@ import io.sentry.ITransportFactory import io.sentry.Integration import io.sentry.Sentry import io.sentry.SentryOptions +import io.sentry.transport.ITransport import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.springframework.boot.context.annotation.UserConfigurations import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean @@ -185,7 +188,9 @@ class EnableSentryTest { class AppConfigWithCustomTransportFactory { @Bean - fun transport() = mock() + fun transport() = mock().also { + whenever(it.create(any(), any())).thenReturn(mock()) + } } @EnableSentry(dsn = "http://key@localhost/proj") diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebfluxIntegrationTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebfluxIntegrationTest.kt index 8dbbd05ebd..3f4628ea3c 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebfluxIntegrationTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebfluxIntegrationTest.kt @@ -145,7 +145,9 @@ class SentryWebfluxIntegrationTest { @SpringBootApplication(exclude = [ReactiveSecurityAutoConfiguration::class, SecurityAutoConfiguration::class]) open class App { - private val transport = mock() + private val transport = mock().also { + whenever(it.isHealthy).thenReturn(true) + } @Bean open fun mockTransportFactory(): ITransportFactory { diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt index 2d7cabae3e..5a8fec3053 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt @@ -6,8 +6,11 @@ import io.sentry.ITransportFactory import io.sentry.Integration import io.sentry.Sentry import io.sentry.SentryOptions +import io.sentry.transport.ITransport import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.springframework.boot.context.annotation.UserConfigurations import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean @@ -185,7 +188,9 @@ class EnableSentryTest { class AppConfigWithCustomTransportFactory { @Bean - fun transport() = mock() + fun transport() = mock().also { + whenever(it.create(any(), any())).thenReturn(mock()) + } } @EnableSentry(dsn = "http://key@localhost/proj") 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 94cd22f505..0d4346c5ae 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 @@ -145,7 +145,9 @@ class SentryWebfluxIntegrationTest { @SpringBootApplication(exclude = [ReactiveSecurityAutoConfiguration::class, SecurityAutoConfiguration::class]) open class App { - private val transport = mock() + private val transport = mock().also { + whenever(it.isHealthy).thenReturn(true) + } @Bean open fun mockTransportFactory(): ITransportFactory { diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt index 8d0466cfaf..2ff144fd9b 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt @@ -2,19 +2,24 @@ package io.sentry.test import io.sentry.ISentryExecutorService +import io.sentry.backpressure.IBackpressureMonitor import org.mockito.kotlin.mock import java.util.concurrent.Callable import java.util.concurrent.Future class ImmediateExecutorService : ISentryExecutorService { override fun submit(runnable: Runnable): Future<*> { - runnable.run() + if (runnable !is IBackpressureMonitor) { + runnable.run() + } return mock() } override fun submit(callable: Callable): Future = mock() override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> { - runnable.run() + if (runnable !is IBackpressureMonitor) { + runnable.run() + } return mock>() } override fun close(timeoutMillis: Long) {} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8f41fb9148..a5d5424b4a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -336,12 +336,14 @@ public final class io/sentry/ExternalOptions { public fun getTracePropagationTargets ()Ljava/util/List; public fun getTracesSampleRate ()Ljava/lang/Double; public fun getTracingOrigins ()Ljava/util/List; + public fun isEnableBackpressureHandling ()Ljava/lang/Boolean; public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean; public fun isEnabled ()Ljava/lang/Boolean; public fun isSendModules ()Ljava/lang/Boolean; public fun setDebug (Ljava/lang/Boolean;)V public fun setDist (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V + public fun setEnableBackpressureHandling (Ljava/lang/Boolean;)V public fun setEnableDeduplication (Ljava/lang/Boolean;)V public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V public fun setEnableTracing (Ljava/lang/Boolean;)V @@ -435,6 +437,7 @@ public final class io/sentry/Hub : io/sentry/IHub { public fun getTransaction ()Lio/sentry/ITransaction; public fun isCrashedLastRun ()Ljava/lang/Boolean; public fun isEnabled ()Z + public fun isHealthy ()Z public fun popScope ()V public fun pushScope ()V public fun removeExtra (Ljava/lang/String;)V @@ -485,6 +488,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun getTransaction ()Lio/sentry/ITransaction; public fun isCrashedLastRun ()Ljava/lang/Boolean; public fun isEnabled ()Z + public fun isHealthy ()Z public fun popScope ()V public fun pushScope ()V public fun removeExtra (Ljava/lang/String;)V @@ -578,6 +582,7 @@ public abstract interface class io/sentry/IHub { public abstract fun getTransaction ()Lio/sentry/ITransaction; public abstract fun isCrashedLastRun ()Ljava/lang/Boolean; public abstract fun isEnabled ()Z + public abstract fun isHealthy ()Z public abstract fun popScope ()V public abstract fun pushScope ()V public abstract fun removeExtra (Ljava/lang/String;)V @@ -718,6 +723,7 @@ public abstract interface class io/sentry/ISentryClient { public abstract fun flush (J)V public abstract fun getRateLimiter ()Lio/sentry/transport/RateLimiter; public abstract fun isEnabled ()Z + public fun isHealthy ()Z } public abstract interface class io/sentry/ISentryExecutorService { @@ -1119,6 +1125,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun getTransaction ()Lio/sentry/ITransaction; public fun isCrashedLastRun ()Ljava/lang/Boolean; public fun isEnabled ()Z + public fun isHealthy ()Z public fun popScope ()V public fun pushScope ()V public fun removeExtra (Ljava/lang/String;)V @@ -1665,6 +1672,7 @@ public final class io/sentry/Sentry { public static fun init (Ljava/lang/String;)V public static fun isCrashedLastRun ()Ljava/lang/Boolean; public static fun isEnabled ()Z + public static fun isHealthy ()Z public static fun popScope ()V public static fun pushScope ()V public static fun removeExtra (Ljava/lang/String;)V @@ -1781,6 +1789,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient { public fun flush (J)V public fun getRateLimiter ()Lio/sentry/transport/RateLimiter; public fun isEnabled ()Z + public fun isHealthy ()Z } public final class io/sentry/SentryCrashLastRunState { @@ -2078,6 +2087,7 @@ public class io/sentry/SentryOptions { public fun addOptionsObserver (Lio/sentry/IOptionsObserver;)V public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public fun addTracingOrigin (Ljava/lang/String;)V + public fun getBackpressureMonitor ()Lio/sentry/backpressure/IBackpressureMonitor; public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; public fun getBeforeSend ()Lio/sentry/SentryOptions$BeforeSendCallback; public fun getBeforeSendTransaction ()Lio/sentry/SentryOptions$BeforeSendTransactionCallback; @@ -2156,6 +2166,7 @@ public class io/sentry/SentryOptions { public fun isAttachThreads ()Z public fun isDebug ()Z public fun isEnableAutoSessionTracking ()Z + public fun isEnableBackpressureHandling ()Z public fun isEnableDeduplication ()Z public fun isEnableExternalConfiguration ()Z public fun isEnablePrettySerializationOutput ()Z @@ -2177,6 +2188,7 @@ public class io/sentry/SentryOptions { public fun setAttachServerName (Z)V public fun setAttachStacktrace (Z)V public fun setAttachThreads (Z)V + public fun setBackpressureMonitor (Lio/sentry/backpressure/IBackpressureMonitor;)V public fun setBeforeBreadcrumb (Lio/sentry/SentryOptions$BeforeBreadcrumbCallback;)V public fun setBeforeSend (Lio/sentry/SentryOptions$BeforeSendCallback;)V public fun setBeforeSendTransaction (Lio/sentry/SentryOptions$BeforeSendTransactionCallback;)V @@ -2191,6 +2203,7 @@ public class io/sentry/SentryOptions { public fun setDistinctId (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V public fun setEnableAutoSessionTracking (Z)V + public fun setEnableBackpressureHandling (Z)V public fun setEnableDeduplication (Z)V public fun setEnableExternalConfiguration (Z)V public fun setEnablePrettySerializationOutput (Z)V @@ -2824,6 +2837,24 @@ public final class io/sentry/UserFeedback$JsonKeys { public fun ()V } +public final class io/sentry/backpressure/BackpressureMonitor : io/sentry/backpressure/IBackpressureMonitor, java/lang/Runnable { + public fun (Lio/sentry/SentryOptions;Lio/sentry/IHub;)V + public fun getDownsampleFactor ()I + public fun run ()V + public fun start ()V +} + +public abstract interface class io/sentry/backpressure/IBackpressureMonitor { + public abstract fun getDownsampleFactor ()I + public abstract fun start ()V +} + +public final class io/sentry/backpressure/NoOpBackpressureMonitor : io/sentry/backpressure/IBackpressureMonitor { + public fun getDownsampleFactor ()I + public static fun getInstance ()Lio/sentry/backpressure/NoOpBackpressureMonitor; + public fun start ()V +} + public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static final field CRASH_MARKER_FILE Ljava/lang/String; public static final field NATIVE_CRASH_MARKER_FILE Ljava/lang/String; @@ -2925,6 +2956,7 @@ public final class io/sentry/clientreport/ClientReportRecorder : io/sentry/clien } public final class io/sentry/clientreport/DiscardReason : java/lang/Enum { + public static final field BACKPRESSURE Lio/sentry/clientreport/DiscardReason; public static final field BEFORE_SEND Lio/sentry/clientreport/DiscardReason; public static final field CACHE_OVERFLOW Lio/sentry/clientreport/DiscardReason; public static final field EVENT_PROCESSOR Lio/sentry/clientreport/DiscardReason; @@ -4432,6 +4464,7 @@ public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ public fun close ()V public fun flush (J)V public fun getRateLimiter ()Lio/sentry/transport/RateLimiter; + public fun isHealthy ()Z public fun send (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } @@ -4447,6 +4480,7 @@ public abstract interface class io/sentry/transport/ICurrentDateProvider { public abstract interface class io/sentry/transport/ITransport : java/io/Closeable { public abstract fun flush (J)V public abstract fun getRateLimiter ()Lio/sentry/transport/RateLimiter; + public fun isHealthy ()Z public fun send (Lio/sentry/SentryEnvelope;)V public abstract fun send (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } @@ -4481,6 +4515,7 @@ public final class io/sentry/transport/RateLimiter { public fun (Lio/sentry/transport/ICurrentDateProvider;Lio/sentry/SentryOptions;)V public fun filter (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/SentryEnvelope; public fun isActiveForCategory (Lio/sentry/DataCategory;)Z + public fun isAnyRateLimitActive ()Z public fun updateRetryAfterLimits (Ljava/lang/String;Ljava/lang/String;I)V } diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index adb25811e0..a34f24df85 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -49,6 +49,7 @@ public final class ExternalOptions { private @Nullable List ignoredCheckIns; private @Nullable Boolean sendModules; + private @Nullable Boolean enableBackpressureHandling; @SuppressWarnings("unchecked") public static @NotNull ExternalOptions from( @@ -131,6 +132,9 @@ public final class ExternalOptions { options.setIgnoredCheckIns(propertiesProvider.getList("ignored-checkins")); + options.setEnableBackpressureHandling( + propertiesProvider.getBooleanProperty("enable-backpressure-handling")); + for (final String ignoredExceptionType : propertiesProvider.getList("ignored-exceptions-for-type")) { try { @@ -398,4 +402,14 @@ public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { public @Nullable List getIgnoredCheckIns() { return ignoredCheckIns; } + + @ApiStatus.Experimental + public void setEnableBackpressureHandling(final @Nullable Boolean enableBackpressureHandling) { + this.enableBackpressureHandling = enableBackpressureHandling; + } + + @ApiStatus.Experimental + public @Nullable Boolean isEnableBackpressureHandling() { + return enableBackpressureHandling; + } } diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index dcd01dec73..a1396cbcaf 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -604,6 +604,11 @@ public void bindClient(final @NotNull ISentryClient client) { } } + @Override + public boolean isHealthy() { + return stack.peek().getClient().isHealthy(); + } + @Override public void flush(long timeoutMillis) { if (!isEnabled()) { @@ -660,9 +665,15 @@ public void flush(long timeoutMillis) { SentryLevel.DEBUG, "Transaction %s was dropped due to sampling decision.", transaction.getEventId()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Transaction); + if (options.getBackpressureMonitor().getDownsampleFactor() > 0) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BACKPRESSURE, DataCategory.Transaction); + } else { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Transaction); + } } else { StackItem item = null; try { diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index b655336314..68a9bdf11d 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -168,6 +168,11 @@ public void bindClient(@NotNull ISentryClient client) { Sentry.bindClient(client); } + @Override + public boolean isHealthy() { + return Sentry.isHealthy(); + } + @Override public void flush(long timeoutMillis) { Sentry.flush(timeoutMillis); diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index a6700df70e..01431043f0 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -329,6 +329,13 @@ default void addBreadcrumb(@NotNull String message, @NotNull String category) { */ void bindClient(@NotNull ISentryClient client); + /** + * Whether the transport is healthy. + * + * @return true if the transport is healthy + */ + boolean isHealthy(); + /** * Flushes events queued up, but keeps the Hub enabled. Not implemented yet. * diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index f6387ea6b4..15b5f25c4b 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -273,4 +273,9 @@ SentryId captureTransaction( @ApiStatus.Internal @Nullable RateLimiter getRateLimiter(); + + @ApiStatus.Internal + default boolean isHealthy() { + return true; + } } diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index e4c93a10ec..d186c69ca2 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -131,6 +131,11 @@ public void configureScope(@NotNull ScopeCallback callback) {} @Override public void bindClient(@NotNull ISentryClient client) {} + @Override + public boolean isHealthy() { + return true; + } + @Override public void flush(long timeoutMillis) {} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 368bfc8b57..67c36c9b6d 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.backpressure.BackpressureMonitor; import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; import io.sentry.config.PropertiesProviderFactory; @@ -391,6 +392,11 @@ private static boolean initConfigurations(final @NotNull SentryOptions options) options.addCollector(new JavaMemoryCollector()); } + if (options.isEnableBackpressureHandling()) { + options.setBackpressureMonitor(new BackpressureMonitor(options, HubAdapter.getInstance())); + options.getBackpressureMonitor().start(); + } + return true; } @@ -731,6 +737,10 @@ public static void bindClient(final @NotNull ISentryClient client) { getCurrentHub().bindClient(client); } + public static boolean isHealthy() { + return getCurrentHub().isHealthy(); + } + /** * Flushes events queued up to the current hub. Not implemented yet. * diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index f6b7fbf947..2973c1f8bc 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -941,6 +941,11 @@ public void flush(final long timeoutMillis) { return transport.getRateLimiter(); } + @Override + public boolean isHealthy() { + return transport.isHealthy(); + } + private boolean sample() { // https://docs.sentry.io/development/sdk-dev/features/#event-sampling if (options.getSampleRate() != null && random != null) { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 7fdbf8d9cf..9a6a962dc9 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1,6 +1,8 @@ package io.sentry; import com.jakewharton.nopen.annotation.Open; +import io.sentry.backpressure.IBackpressureMonitor; +import io.sentry.backpressure.NoOpBackpressureMonitor; import io.sentry.cache.IEnvelopeCache; import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; @@ -440,6 +442,11 @@ public class SentryOptions { /** Contains a list of monitor slugs for which check-ins should not be sent. */ @ApiStatus.Experimental private @Nullable List ignoredCheckIns = null; + @ApiStatus.Experimental + private @NotNull IBackpressureMonitor backpressureMonitor = NoOpBackpressureMonitor.getInstance(); + + @ApiStatus.Experimental private boolean enableBackpressureHandling = false; + /** * Adds an event processor * @@ -2188,6 +2195,27 @@ public void setConnectionStatusProvider( this.connectionStatusProvider = connectionStatusProvider; } + @ApiStatus.Internal + @NotNull + public IBackpressureMonitor getBackpressureMonitor() { + return backpressureMonitor; + } + + @ApiStatus.Internal + public void setBackpressureMonitor(final @NotNull IBackpressureMonitor backpressureMonitor) { + this.backpressureMonitor = backpressureMonitor; + } + + @ApiStatus.Experimental + public void setEnableBackpressureHandling(final boolean enableBackpressureHandling) { + this.enableBackpressureHandling = enableBackpressureHandling; + } + + @ApiStatus.Experimental + public boolean isEnableBackpressureHandling() { + return enableBackpressureHandling; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { @@ -2403,6 +2431,9 @@ public void merge(final @NotNull ExternalOptions options) { final List ignoredCheckIns = new ArrayList<>(options.getIgnoredCheckIns()); setIgnoredCheckIns(ignoredCheckIns); } + if (options.isEnableBackpressureHandling() != null) { + setEnableBackpressureHandling(options.isEnableBackpressureHandling()); + } } private @NotNull SdkVersion createSdkVersion() { diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index 750a728fa7..3b83a815cf 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -72,11 +72,15 @@ TracesSamplingDecision sample(final @NotNull SamplingContext samplingContext) { Boolean.TRUE.equals(isEnableTracing) ? DEFAULT_TRACES_SAMPLE_RATE : null; final @Nullable Double tracesSampleRateOrDefault = tracesSampleRateFromOptions == null ? defaultSampleRate : tracesSampleRateFromOptions; + final @NotNull Double downsampleFactor = + Math.pow(2, options.getBackpressureMonitor().getDownsampleFactor()); + final @Nullable Double downsampledTracesSampleRate = + tracesSampleRateOrDefault == null ? null : tracesSampleRateOrDefault / downsampleFactor; - if (tracesSampleRateOrDefault != null) { + if (downsampledTracesSampleRate != null) { return new TracesSamplingDecision( - sample(tracesSampleRateOrDefault), - tracesSampleRateOrDefault, + sample(downsampledTracesSampleRate), + downsampledTracesSampleRate, profilesSampled, profilesSampleRate); } diff --git a/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java b/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java new file mode 100644 index 0000000000..2008a38c76 --- /dev/null +++ b/sentry/src/main/java/io/sentry/backpressure/BackpressureMonitor.java @@ -0,0 +1,71 @@ +package io.sentry.backpressure; + +import io.sentry.IHub; +import io.sentry.ISentryExecutorService; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; + +public final class BackpressureMonitor implements IBackpressureMonitor, Runnable { + static final int MAX_DOWNSAMPLE_FACTOR = 10; + private static final int INITIAL_CHECK_DELAY_IN_MS = 500; + private static final int CHECK_INTERVAL_IN_MS = 10 * 1000; + + private final @NotNull SentryOptions sentryOptions; + private final @NotNull IHub hub; + private int downsampleFactor = 0; + + public BackpressureMonitor(final @NotNull SentryOptions sentryOptions, final @NotNull IHub hub) { + this.sentryOptions = sentryOptions; + this.hub = hub; + } + + @Override + public void start() { + reschedule(INITIAL_CHECK_DELAY_IN_MS); + } + + @Override + public void run() { + checkHealth(); + reschedule(CHECK_INTERVAL_IN_MS); + } + + @Override + public int getDownsampleFactor() { + return downsampleFactor; + } + + void checkHealth() { + if (isHealthy()) { + if (downsampleFactor > 0) { + sentryOptions + .getLogger() + .log(SentryLevel.DEBUG, "Health check positive, reverting to normal sampling."); + } + downsampleFactor = 0; + } else { + if (downsampleFactor < MAX_DOWNSAMPLE_FACTOR) { + downsampleFactor++; + sentryOptions + .getLogger() + .log( + SentryLevel.DEBUG, + "Health check negative, downsampling with a factor of %d", + downsampleFactor); + } + } + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void reschedule(final int delay) { + final @NotNull ISentryExecutorService executorService = sentryOptions.getExecutorService(); + if (!executorService.isClosed()) { + executorService.schedule(this, delay); + } + } + + private boolean isHealthy() { + return hub.isHealthy(); + } +} diff --git a/sentry/src/main/java/io/sentry/backpressure/IBackpressureMonitor.java b/sentry/src/main/java/io/sentry/backpressure/IBackpressureMonitor.java new file mode 100644 index 0000000000..05cf681950 --- /dev/null +++ b/sentry/src/main/java/io/sentry/backpressure/IBackpressureMonitor.java @@ -0,0 +1,10 @@ +package io.sentry.backpressure; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public interface IBackpressureMonitor { + void start(); + + int getDownsampleFactor(); +} diff --git a/sentry/src/main/java/io/sentry/backpressure/NoOpBackpressureMonitor.java b/sentry/src/main/java/io/sentry/backpressure/NoOpBackpressureMonitor.java new file mode 100644 index 0000000000..edbf660e24 --- /dev/null +++ b/sentry/src/main/java/io/sentry/backpressure/NoOpBackpressureMonitor.java @@ -0,0 +1,22 @@ +package io.sentry.backpressure; + +public final class NoOpBackpressureMonitor implements IBackpressureMonitor { + + private static final NoOpBackpressureMonitor instance = new NoOpBackpressureMonitor(); + + private NoOpBackpressureMonitor() {} + + public static NoOpBackpressureMonitor getInstance() { + return instance; + } + + @Override + public void start() { + // do nothing + } + + @Override + public int getDownsampleFactor() { + return 0; + } +} diff --git a/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java b/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java index 887dadc502..91a56cf313 100644 --- a/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java +++ b/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java @@ -10,7 +10,8 @@ public enum DiscardReason { NETWORK_ERROR("network_error"), SAMPLE_RATE("sample_rate"), BEFORE_SEND("before_send"), - EVENT_PROCESSOR("event_processor"); // also for ignored exceptions + EVENT_PROCESSOR("event_processor"), // also for ignored exceptions + BACKPRESSURE("backpressure"); private final String reason; diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index 7efbcbcaf3..6636ce517f 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -5,6 +5,7 @@ import io.sentry.ILogger; import io.sentry.RequestDetails; import io.sentry.SentryDate; +import io.sentry.SentryDateProvider; import io.sentry.SentryEnvelope; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -46,7 +47,10 @@ public AsyncHttpTransport( final @NotNull RequestDetails requestDetails) { this( initExecutor( - options.getMaxQueueSize(), options.getEnvelopeDiskCache(), options.getLogger()), + options.getMaxQueueSize(), + options.getEnvelopeDiskCache(), + options.getLogger(), + options.getDateProvider()), options, rateLimiter, transportGate, @@ -124,7 +128,8 @@ public void flush(long timeoutMillis) { private static QueuedThreadPoolExecutor initExecutor( final int maxQueueSize, final @NotNull IEnvelopeCache envelopeCache, - final @NotNull ILogger logger) { + final @NotNull ILogger logger, + final @NotNull SentryDateProvider dateProvider) { final RejectedExecutionHandler storeEvents = (r, executor) -> { @@ -141,7 +146,7 @@ private static QueuedThreadPoolExecutor initExecutor( }; return new QueuedThreadPoolExecutor( - 1, maxQueueSize, new AsyncConnectionThreadFactory(), storeEvents, logger); + 1, maxQueueSize, new AsyncConnectionThreadFactory(), storeEvents, logger, dateProvider); } @Override @@ -149,6 +154,13 @@ private static QueuedThreadPoolExecutor initExecutor( return rateLimiter; } + @Override + public boolean isHealthy() { + boolean anyRateLimitActive = rateLimiter.isAnyRateLimitActive(); + boolean didRejectRecently = executor.didRejectRecently(); + return !anyRateLimitActive && !didRejectRecently; + } + @Override public void close() throws IOException { executor.shutdown(); diff --git a/sentry/src/main/java/io/sentry/transport/ITransport.java b/sentry/src/main/java/io/sentry/transport/ITransport.java index 09fc034246..b7a38d20ed 100644 --- a/sentry/src/main/java/io/sentry/transport/ITransport.java +++ b/sentry/src/main/java/io/sentry/transport/ITransport.java @@ -15,6 +15,10 @@ default void send(@NotNull SentryEnvelope envelope) throws IOException { send(envelope, new Hint()); } + default boolean isHealthy() { + return true; + } + /** * Flushes events queued up, but keeps the client enabled. Not implemented yet. * diff --git a/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java b/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java index ad79eff4fb..f8d35a6888 100644 --- a/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java +++ b/sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java @@ -1,6 +1,9 @@ package io.sentry.transport; +import io.sentry.DateUtils; import io.sentry.ILogger; +import io.sentry.SentryDate; +import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import java.util.concurrent.CancellationException; import java.util.concurrent.Future; @@ -20,10 +23,14 @@ * *

This class is not public because it is used solely in {@link AsyncHttpTransport}. */ +@SuppressWarnings("UnusedVariable") final class QueuedThreadPoolExecutor extends ThreadPoolExecutor { private final int maxQueueSize; + private @Nullable SentryDate lastRejectTimestamp = null; private final @NotNull ILogger logger; + private final @NotNull SentryDateProvider dateProvider; private final @NotNull ReusableCountLatch unfinishedTasksCount = new ReusableCountLatch(); + private static final long RECENT_THRESHOLD = DateUtils.millisToNanos(2 * 1000); /** * Creates a new instance of the thread pool. @@ -38,7 +45,8 @@ public QueuedThreadPoolExecutor( final int maxQueueSize, final @NotNull ThreadFactory threadFactory, final @NotNull RejectedExecutionHandler rejectedExecutionHandler, - final @NotNull ILogger logger) { + final @NotNull ILogger logger, + final @NotNull SentryDateProvider dateProvider) { // similar to Executors.newSingleThreadExecutor, but with a max queue size control super( corePoolSize, @@ -50,6 +58,7 @@ public QueuedThreadPoolExecutor( rejectedExecutionHandler); this.maxQueueSize = maxQueueSize; this.logger = logger; + this.dateProvider = dateProvider; } @Override @@ -58,6 +67,7 @@ public Future submit(final @NotNull Runnable task) { unfinishedTasksCount.increment(); return super.submit(task); } else { + lastRejectTimestamp = dateProvider.now(); // if the thread pool is full, we don't cache it logger.log(SentryLevel.WARNING, "Submit cancelled"); return new CancelledFuture<>(); @@ -84,10 +94,20 @@ void waitTillIdle(final long timeoutMillis) { } } - private boolean isSchedulingAllowed() { + public boolean isSchedulingAllowed() { return unfinishedTasksCount.getCount() < maxQueueSize; } + public boolean didRejectRecently() { + final @Nullable SentryDate lastReject = this.lastRejectTimestamp; + if (lastReject == null) { + return false; + } + + long diff = dateProvider.now().diff(lastReject); + return diff < RECENT_THRESHOLD; + } + static final class CancelledFuture implements Future { @Override public boolean cancel(final boolean mayInterruptIfRunning) { diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index ed4c04c630..8cc08fd8f6 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -113,6 +113,22 @@ public boolean isActiveForCategory(final @NotNull DataCategory dataCategory) { return false; } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) + public boolean isAnyRateLimitActive() { + final Date currentDate = new Date(currentDateProvider.getCurrentTimeMillis()); + + for (DataCategory dataCategory : sentryRetryAfterLimit.keySet()) { + final Date dateCategory = sentryRetryAfterLimit.get(dataCategory); + if (dateCategory != null) { + if (!currentDate.after(dateCategory)) { + return true; + } + } + } + + return false; + } + /** * It marks the hint when sending has failed, so it's not necessary to wait the timeout * diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 929db8ad06..7abc5de474 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -268,6 +268,13 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with enableBackpressureHandling set to true`() { + withPropertiesFile("enable-backpressure-handling=true") { options -> + assertTrue(options.isEnableBackpressureHandling == true) + } + } + private fun withPropertiesFile(textLines: List = emptyList(), logger: ILogger = mock(), fn: (ExternalOptions) -> Unit) { // create a sentry.properties file in temporary folder val temporaryFolder = TemporaryFolder() diff --git a/sentry/src/test/java/io/sentry/HubTest.kt b/sentry/src/test/java/io/sentry/HubTest.kt index 6e4ff587a1..69950001fc 100644 --- a/sentry/src/test/java/io/sentry/HubTest.kt +++ b/sentry/src/test/java/io/sentry/HubTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.backpressure.IBackpressureMonitor import io.sentry.cache.EnvelopeCache import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason @@ -1439,6 +1440,28 @@ class HubTest { listOf(DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Transaction.category, 1)) ) } + + @Test + fun `transactions lost due to sampling caused by backpressure are recorded as lost`() { + val options = SentryOptions() + options.cacheDirPath = file.absolutePath + options.dsn = "https://key@sentry.io/proj" + options.setSerializer(mock()) + val sut = Hub(options) + val mockClient = mock() + sut.bindClient(mockClient) + val mockBackpressureMonitor = mock() + options.backpressureMonitor = mockBackpressureMonitor + whenever(mockBackpressureMonitor.downsampleFactor).thenReturn(1) + + val sentryTracer = SentryTracer(TransactionContext("name", "op", TracesSamplingDecision(false)), sut) + sentryTracer.finish() + + assertClientReport( + options.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.BACKPRESSURE.reason, DataCategory.Transaction.category, 1)) + ) + } //endregion //region profiling tests diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 3fcc9fa88c..989103507f 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.backpressure.NoOpBackpressureMonitor import io.sentry.util.StringUtils import org.mockito.kotlin.mock import java.io.File @@ -367,6 +368,7 @@ class SentryOptionsTest { externalOptions.isEnablePrettySerializationOutput = false externalOptions.isSendModules = false externalOptions.ignoredCheckIns = listOf("slug1", "slug-B") + externalOptions.isEnableBackpressureHandling = true val options = SentryOptions() @@ -396,6 +398,7 @@ class SentryOptionsTest { assertFalse(options.isEnablePrettySerializationOutput) assertFalse(options.isSendModules) assertEquals(listOf("slug1", "slug-B"), options.ignoredCheckIns) + assertTrue(options.isEnableBackpressureHandling) } @Test @@ -527,4 +530,10 @@ class SentryOptionsTest { fun `when options are initialized, sendModules is set to true by default`() { assertTrue(SentryOptions().isSendModules) } + + @Test + fun `when options are initialized, enableBackpressureHandling is set to false by default`() { + assertFalse(SentryOptions().isEnableBackpressureHandling) + assertTrue(SentryOptions().backpressureMonitor is NoOpBackpressureMonitor) + } } diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 026301ee44..e284cbb4f1 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1,5 +1,7 @@ package io.sentry +import io.sentry.backpressure.BackpressureMonitor +import io.sentry.backpressure.NoOpBackpressureMonitor import io.sentry.cache.EnvelopeCache import io.sentry.cache.IEnvelopeCache import io.sentry.internal.debugmeta.IDebugMetaLoader @@ -920,6 +922,29 @@ class SentryTest { assertEquals("op-child", span.operation) } + @Test + fun `backpressure monitor is a NoOp if handling is disabled`() { + var sentryOptions: SentryOptions? = null + Sentry.init({ + it.dsn = dsn + it.isEnableBackpressureHandling = false + sentryOptions = it + }) + assertIs(sentryOptions?.backpressureMonitor) + } + + @Test + fun `backpressure monitor is set if handling is enabled`() { + var sentryOptions: SentryOptions? = null + + Sentry.init({ + it.dsn = dsn + it.isEnableBackpressureHandling = true + sentryOptions = it + }) + assertIs(sentryOptions?.backpressureMonitor) + } + private class InMemoryOptionsObserver : IOptionsObserver { var release: String? = null private set diff --git a/sentry/src/test/java/io/sentry/backpressure/BackpressureMonitorTest.kt b/sentry/src/test/java/io/sentry/backpressure/BackpressureMonitorTest.kt new file mode 100644 index 0000000000..c010c97238 --- /dev/null +++ b/sentry/src/test/java/io/sentry/backpressure/BackpressureMonitorTest.kt @@ -0,0 +1,83 @@ +package io.sentry.backpressure + +import io.sentry.IHub +import io.sentry.ISentryExecutorService +import io.sentry.SentryOptions +import io.sentry.backpressure.BackpressureMonitor.MAX_DOWNSAMPLE_FACTOR +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.Future +import kotlin.test.Test +import kotlin.test.assertEquals + +class BackpressureMonitorTest { + + class Fixture { + + val options = SentryOptions() + val hub = mock() + val executor = mock() + fun getSut(): BackpressureMonitor { + options.executorService = executor + whenever(executor.isClosed).thenReturn(false) + whenever(executor.schedule(any(), any())).thenReturn(mock>()) + return BackpressureMonitor(options, hub) + } + } + + val fixture = Fixture() + + @Test + fun `starts off with downsampleFactor 0`() { + val sut = fixture.getSut() + assertEquals(0, sut.downsampleFactor) + } + + @Test + fun `downsampleFactor increases with negative health checks up to max`() { + val sut = fixture.getSut() + whenever(fixture.hub.isHealthy).thenReturn(false) + assertEquals(0, sut.downsampleFactor) + + (1..MAX_DOWNSAMPLE_FACTOR).forEach { i -> + sut.checkHealth() + assertEquals(i, sut.downsampleFactor) + } + + assertEquals(MAX_DOWNSAMPLE_FACTOR, sut.downsampleFactor) + sut.checkHealth() + assertEquals(MAX_DOWNSAMPLE_FACTOR, sut.downsampleFactor) + } + + @Test + fun `downsampleFactor goes back to 0 after positive health check`() { + val sut = fixture.getSut() + whenever(fixture.hub.isHealthy).thenReturn(false) + assertEquals(0, sut.downsampleFactor) + + sut.checkHealth() + assertEquals(1, sut.downsampleFactor) + + whenever(fixture.hub.isHealthy).thenReturn(true) + sut.checkHealth() + assertEquals(0, sut.downsampleFactor) + } + + @Test + fun `schedules on start`() { + val sut = fixture.getSut() + sut.start() + + verify(fixture.executor).schedule(any(), any()) + } + + @Test + fun `reschedules on run`() { + val sut = fixture.getSut() + sut.run() + + verify(fixture.executor).schedule(any(), any()) + } +} diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index 2982e9567b..182763c161 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -402,6 +402,38 @@ class AsyncHttpTransportTest { assertTrue(called) } + @Test + fun `is healthy if not rate limited and not rejected recently`() { + whenever(fixture.rateLimiter.isAnyRateLimitActive()).thenReturn(false) + whenever(fixture.executor.didRejectRecently()).thenReturn(false) + + assertTrue(fixture.getSUT().isHealthy) + } + + @Test + fun `is unhealthy if rate limited and not rejected recently`() { + whenever(fixture.rateLimiter.isAnyRateLimitActive()).thenReturn(true) + whenever(fixture.executor.didRejectRecently()).thenReturn(false) + + assertFalse(fixture.getSUT().isHealthy) + } + + @Test + fun `is unhealthy if not rate limited but rejected recently`() { + whenever(fixture.rateLimiter.isAnyRateLimitActive()).thenReturn(false) + whenever(fixture.executor.didRejectRecently()).thenReturn(true) + + assertFalse(fixture.getSUT().isHealthy) + } + + @Test + fun `is unhealthy if rate limited and rejected recently`() { + whenever(fixture.rateLimiter.isAnyRateLimitActive()).thenReturn(true) + whenever(fixture.executor.didRejectRecently()).thenReturn(true) + + assertFalse(fixture.getSUT().isHealthy) + } + private fun createSession(): Session { return Session("123", User(), "env", "release") } diff --git a/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt b/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt index 7ce0da4eb1..d73ba41b27 100644 --- a/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt +++ b/sentry/src/test/java/io/sentry/transport/QueuedThreadPoolExecutorTest.kt @@ -1,5 +1,6 @@ package io.sentry.transport +import io.sentry.SentryNanotimeDateProvider import org.mockito.kotlin.mock import java.util.concurrent.CountDownLatch import java.util.concurrent.ThreadFactory @@ -29,7 +30,14 @@ class QueuedThreadPoolExecutorTest { } fun getSut(): QueuedThreadPoolExecutor = - QueuedThreadPoolExecutor(maxQueueSize + 1, maxQueueSize, threadFactory, DiscardPolicy(), mock()) + QueuedThreadPoolExecutor( + maxQueueSize + 1, + maxQueueSize, + threadFactory, + DiscardPolicy(), + mock(), + SentryNanotimeDateProvider() + ) } private val fixture = Fixture() @@ -79,6 +87,9 @@ class QueuedThreadPoolExecutorTest { @Test fun `limits the queue size`() { val sut = fixture.getSut() + + assertFalse(sut.didRejectRecently()) + // using this we're waiting for the submitted jobs to be unblocked val jobBlocker = Object() @@ -111,6 +122,8 @@ class QueuedThreadPoolExecutorTest { var f = sut.submit { synchronized(jobBlocker) { jobBlocker.wait() } } assertTrue(f.isCancelled, "A task above the queue size should have been cancelled.") + assertTrue(sut.didRejectRecently()) + // wake up a single job and wait on the main thread for that to finish synchronized(jobBlocker) { jobBlocker.notify() } atLeastOneFinished.await() diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index fa00a79f78..063f0b9b26 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -32,8 +32,10 @@ import java.io.File import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class RateLimiterTest { @@ -265,4 +267,18 @@ class RateLimiterTest { verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(profileItem)) verifyNoMoreInteractions(fixture.clientReportRecorder) } + + @Test + fun `any limit can be checked`() { + val rateLimiter = fixture.getSUT() + whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0) + val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) + val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) + + assertFalse(rateLimiter.isAnyRateLimitActive) + + rateLimiter.updateRetryAfterLimits("50:transaction:key, 1:default;error;security:organization", null, 1) + + assertTrue(rateLimiter.isAnyRateLimitActive) + } }