From 0d0c782f7b8b51071090eee38b9553a653569919 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 29 Jul 2025 17:03:36 +0200 Subject: [PATCH 01/21] Do not report cached events as lost --- .../core/cache/AndroidEnvelopeCache.java | 1 + sentry/api/sentry.api | 2 + .../java/io/sentry/cache/EnvelopeCache.java | 1 + .../java/io/sentry/cache/IEnvelopeCache.java | 9 +++- .../sentry/transport/AsyncHttpTransport.java | 43 +++++++++++-------- .../sentry/transport/NoOpEnvelopeCache.java | 6 +++ 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index fb5e81cfa9b..6e7163f36c6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -47,6 +47,7 @@ public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { this.currentDateProvider = currentDateProvider; } + @SuppressWarnings("deprecation") @Override public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { super.store(envelope, hint); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 99d323cf0a8..df0a0ea0998 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4357,6 +4357,7 @@ public abstract interface class io/sentry/cache/IEnvelopeCache : java/lang/Itera public abstract fun discard (Lio/sentry/SentryEnvelope;)V public fun store (Lio/sentry/SentryEnvelope;)V public abstract fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOptionsObserver { @@ -6659,6 +6660,7 @@ public final class io/sentry/transport/NoOpEnvelopeCache : io/sentry/cache/IEnve public static fun getInstance ()Lio/sentry/transport/NoOpEnvelopeCache; public fun iterator ()Ljava/util/Iterator; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } public final class io/sentry/transport/NoOpTransport : io/sentry/transport/ITransport { diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 8117002daf9..1ca4c6a1e0a 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -93,6 +93,7 @@ public EnvelopeCache( previousSessionLatch = new CountDownLatch(1); } + @SuppressWarnings("deprecation") @Override public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); diff --git a/sentry/src/main/java/io/sentry/cache/IEnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/IEnvelopeCache.java index ba607656c63..ec25ce32884 100644 --- a/sentry/src/main/java/io/sentry/cache/IEnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/IEnvelopeCache.java @@ -6,10 +6,17 @@ public interface IEnvelopeCache extends Iterable { + @Deprecated void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint); + default boolean storeEnvelope(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + store(envelope, hint); + return true; + } + + @Deprecated default void store(@NotNull SentryEnvelope envelope) { - store(envelope, new Hint()); + storeEnvelope(envelope, new Hint()); } void discard(@NotNull SentryEnvelope envelope); diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index 24f954c0c10..5f5162dac93 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -139,7 +139,7 @@ private static QueuedThreadPoolExecutor initExecutor( final EnvelopeSender envelopeSender = (EnvelopeSender) r; if (!HintUtils.hasType(envelopeSender.hint, Cached.class)) { - envelopeCache.store(envelopeSender.envelope, envelopeSender.hint); + envelopeCache.storeEnvelope(envelopeSender.envelope, envelopeSender.hint); } markHintWhenSendingFailed(envelopeSender.hint, true); @@ -268,7 +268,7 @@ public void run() { TransportResult result = this.failedResult; envelope.getHeader().setSentAt(null); - envelopeCache.store(envelope, hint); + boolean cached = envelopeCache.storeEnvelope(envelope, hint); HintUtils.runIfHasType( hint, @@ -308,14 +308,17 @@ public void run() { // ignore e.g. 429 as we're not the ones actively dropping if (result.getResponseCode() >= 400 && result.getResponseCode() != 429) { - HintUtils.runIfDoesNotHaveType( - hint, - Retryable.class, - (hint) -> { - options - .getClientReportRecorder() - .recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelopeWithClientReport); - }); + if (!cached) { + HintUtils.runIfDoesNotHaveType( + hint, + Retryable.class, + (hint) -> { + options + .getClientReportRecorder() + .recordLostEnvelope( + DiscardReason.NETWORK_ERROR, envelopeWithClientReport); + }); + } } throw new IllegalStateException(message); @@ -329,10 +332,12 @@ public void run() { retryable.setRetry(true); }, (hint, clazz) -> { - LogUtils.logNotInstanceOf(clazz, hint, options.getLogger()); - options - .getClientReportRecorder() - .recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelopeWithClientReport); + if (!cached) { + LogUtils.logNotInstanceOf(clazz, hint, options.getLogger()); + options + .getClientReportRecorder() + .recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelopeWithClientReport); + } }); throw new IllegalStateException("Sending the event failed.", e); } @@ -345,10 +350,12 @@ public void run() { retryable.setRetry(true); }, (hint, clazz) -> { - LogUtils.logNotInstanceOf(clazz, hint, options.getLogger()); - options - .getClientReportRecorder() - .recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelope); + if (!cached) { + LogUtils.logNotInstanceOf(clazz, hint, options.getLogger()); + options + .getClientReportRecorder() + .recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelope); + } }); } return result; diff --git a/sentry/src/main/java/io/sentry/transport/NoOpEnvelopeCache.java b/sentry/src/main/java/io/sentry/transport/NoOpEnvelopeCache.java index d4902cf8b26..6140394f3ca 100644 --- a/sentry/src/main/java/io/sentry/transport/NoOpEnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/transport/NoOpEnvelopeCache.java @@ -14,9 +14,15 @@ public static NoOpEnvelopeCache getInstance() { return instance; } + @SuppressWarnings("deprecation") @Override public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) {} + @Override + public boolean storeEnvelope(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + return false; + } + @Override public void discard(@NotNull SentryEnvelope envelope) {} From d521a6084dffd8ea62c4487779d44383406941a0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 29 Jul 2025 18:12:07 +0200 Subject: [PATCH 02/21] E2E tests for OpenTelemetry based console sample (#4563) * e2e tests for console app * fix test failures by waiting for 10s after first try to find envelopes * add system-test-runner.py script to replace bash scripts for running e2e / system tests * use py script for ci, cleanup, makefile * Format code * remove bash scripts * install requests module * api * fix gh script * Implement E2E tests for OTel based console sample * fixes after merge * Format code * e2e tests for console app * Implement E2E tests for OTel based console sample * fixes after merge * Format code * api * Reduce scope forking when using OpenTelemetry (#4565) * Reduce scope forking in OpenTelemetry * Format code * api * changelog --------- Co-authored-by: Sentry Github Bot * SDKs send queue is no longer shutdown immediately on re-init (#4564) * Let queue drain on a restart * Format code * Format code * Update sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt * Let queue drain on a restart * Format code * Format code * Update sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt * adapt tests * changelog --------- Co-authored-by: Sentry Github Bot --------- Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 5 + .../api/sentry-opentelemetry-bootstrap.api | 1 + .../opentelemetry/SentryContextStorage.java | 5 + .../opentelemetry/SentryContextWrapper.java | 2 +- .../build.gradle.kts | 75 ++++++++++++- .../java/io/sentry/samples/console/Main.java | 94 +++------------- .../src/test/kotlin/sentry/DummyTest.kt | 12 +++ .../ConsoleApplicationSystemTest.kt | 101 ++++++++++++++++++ .../java/io/sentry/samples/console/Main.java | 21 ++-- .../api/sentry-system-test-support.api | 3 +- .../io/sentry/systemtest/util/TestHelper.kt | 13 ++- .../io/sentry/ShutdownHookIntegration.java | 3 +- .../sentry/transport/AsyncHttpTransport.java | 31 +++--- .../transport/AsyncHttpTransportTest.kt | 21 ++-- test/system-test-runner.py | 1 + 15 files changed, 269 insertions(+), 119 deletions(-) create mode 100644 sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt create mode 100644 sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dd255f9fb2e..85dc62b5559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ - This was causing Sentry SDK to log warnings: "Sentry Log is disabled and this 'logger' call is a no-op." - Do not use Sentry logging API in Log4j2 if logs are disabled ([#4573](https://github.com/getsentry/sentry-java/pull/4573)) - This was causing Sentry SDK to log warnings: "Sentry Log is disabled and this 'logger' call is a no-op." +- SDKs send queue is no longer shutdown immediately on re-init ([#4564](https://github.com/getsentry/sentry-java/pull/4564)) + - This means we're no longer losing events that have been enqueued right before SDK re-init. +- Reduce scope forking when using OpenTelemetry ([#4565](https://github.com/getsentry/sentry-java/pull/4565)) + - `Sentry.withScope` now has the correct current scope passed to the callback. Previously our OpenTelemetry integration forked scopes an additional. + - Overall the SDK is now forking scopes a bit less often. ## 8.17.0 diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index 8e6b59b4be2..3a63bf04d98 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -151,6 +151,7 @@ public final class io/sentry/opentelemetry/SentryContextStorage : io/opentelemet public fun (Lio/opentelemetry/context/ContextStorage;)V public fun attach (Lio/opentelemetry/context/Context;)Lio/opentelemetry/context/Scope; public fun current ()Lio/opentelemetry/context/Context; + public fun root ()Lio/opentelemetry/context/Context; } public final class io/sentry/opentelemetry/SentryContextStorageProvider : io/opentelemetry/context/ContextStorageProvider { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java index 4f3efa40c29..5a916a9ecab 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java @@ -38,4 +38,9 @@ public Scope attach(Context toAttach) { public Context current() { return contextStorage.current(); } + + @Override + public Context root() { + return SentryContextWrapper.wrap(ContextStorage.super.root()); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java index a0213bafe65..1b0581fc9e2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java @@ -32,7 +32,7 @@ public Context with(final @NotNull ContextKey contextKey, V v) { if (isOpentelemetrySpan(contextKey)) { return forkCurrentScope(modifiedContext); } else { - return modifiedContext; + return new SentryContextWrapper(modifiedContext); } } diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 945d906df8a..8821c25626b 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -1,14 +1,83 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { java application + kotlin("jvm") alias(libs.plugins.gradle.versions) + id("com.github.johnrengelman.shadow") version "8.1.1" } application { mainClass.set("io.sentry.samples.console.Main") } +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() +} + +tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j.api) + testImplementation(libs.slf4j.jdk14) +} + +// Configure the Shadow JAR (executable JAR with all dependencies) +tasks.shadowJar { + manifest { attributes["Main-Class"] = "io.sentry.samples.console.Main" } + archiveClassifier.set("") // Remove the classifier so it replaces the regular JAR + mergeServiceFiles() } -dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) } +// Make the regular jar task depend on shadowJar +tasks.jar { + enabled = false + dependsOn(tasks.shadowJar) +} + +// Fix the startScripts task dependency +tasks.startScripts { dependsOn(tasks.shadowJar) } + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + filter { includeTestsMatching("io.sentry.systemtest*") } +} + +tasks.named("test").configure { + require(this is Test) + + filter { excludeTestsMatching("io.sentry.systemtest.*") } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java index c27aad737b1..8af939c32bf 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java @@ -4,6 +4,7 @@ import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; import io.sentry.Breadcrumb; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -17,90 +18,23 @@ import io.sentry.protocol.Message; import io.sentry.protocol.User; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public class Main { public static void main(String[] args) throws InterruptedException { - Sentry.init( - options -> { - // NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in - // your Sentry project/dashboard - options.setDsn( - "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563"); - - // All events get assigned to the release. See more at - // https://docs.sentry.io/workflow/releases/ - options.setRelease("io.sentry.samples.console@3.0.0+1"); - - // Modifications to event before it goes out. Could replace the event altogether - options.setBeforeSend( - (event, hint) -> { - // Drop an event altogether: - if (event.getTag("SomeTag") != null) { - return null; - } - return event; - }); - - options.setBeforeSendTransaction( - (transaction, hint) -> { - // Drop a transaction: - if (transaction.getTag("SomeTransactionTag") != null) { - return null; - } - - return transaction; - }); - - // Allows inspecting and modifying, returning a new or simply rejecting (returning null) - options.setBeforeBreadcrumb( - (breadcrumb, hint) -> { - // Don't add breadcrumbs with message containing: - if (breadcrumb.getMessage() != null - && breadcrumb.getMessage().contains("bad breadcrumb")) { - return null; - } - return breadcrumb; - }); - - // Configure the background worker which sends events to sentry: - // Wait up to 5 seconds before shutdown while there are events to send. - options.setShutdownTimeoutMillis(5000); - - // Enable SDK logging with Debug level - options.setDebug(true); - // To change the verbosity, use: - // By default it's DEBUG. - // options.setDiagnosticLevel( - // SentryLevel - // .ERROR); // A good option to have SDK debug log in prod is to use - // only level - // ERROR here. - options.setEnablePrettySerializationOutput(false); - - // Exclude frames from some packages from being "inApp" so are hidden by default in Sentry - // UI: - options.addInAppExclude("org.jboss"); - - // Include frames from our package - options.addInAppInclude("io.sentry.samples"); - - // Performance configuration options - // Set what percentage of traces should be collected - options.setTracesSampleRate(1.0); // set 0.5 to send 50% of traces - - // Determine traces sample rate based on the sampling context - // options.setTracesSampler( - // context -> { - // // only 10% of transactions with "/product" prefix will be collected - // if (!context.getTransactionContext().getName().startsWith("/products")) - // { - // return 0.1; - // } else { - // return 0.5; - // } - // }); - }); + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .addPropertiesSupplier( + () -> { + final Map properties = new HashMap<>(); + properties.put("otel.logs.exporter", "none"); + properties.put("otel.metrics.exporter", "none"); + properties.put("otel.traces.exporter", "none"); + return properties; + }) + .build(); Sentry.addBreadcrumb( "A 'bad breadcrumb' that will be rejected because of 'BeforeBreadcrumb callback above.'"); diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertTrue + +class DummyTest { + @Test + fun `the only test`() { + // only needed to have more than 0 tests and not fail the build + assertTrue(true) + } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt new file mode 100644 index 00000000000..427c930653b --- /dev/null +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -0,0 +1,101 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ConsoleApplicationSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8000") + testHelper.reset() + } + + @Test + fun `console application sends expected events when run as JAR`() { + val jarFile = testHelper.findJar("sentry-samples-console-opentelemetry-noagent") + val process = + testHelper.launch( + jarFile, + mapOf( + "SENTRY_DSN" to testHelper.dsn, + // "SENTRY_AUTO_INIT" to "false", + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", + "SENTRY_DEBUG" to "true", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + "OTEL_TRACES_EXPORTER" to "none", + ), + ) + + process.waitFor(30, TimeUnit.SECONDS) + assertEquals(0, process.exitValue()) + + // Verify that we received the expected events + verifyExpectedEvents() + } + + private fun verifyExpectedEvents() { + // Verify we received a "Fatal message!" event + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" + } + + // Verify we received a "Some warning!" event + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" + } + + // Verify we received the RuntimeException + testHelper.ensureErrorReceived { event -> + event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == + true + } + + // Verify we received the detailed event with fingerprint + testHelper.ensureErrorReceived { event -> + event.message?.message == "Detailed event" && + event.fingerprints?.contains("NewClientDebug") == true && + event.level?.name == "DEBUG" + } + + // Verify we received transaction events + testHelper.ensureTransactionReceived { transaction, _ -> + transaction.transaction == "transaction name" && + transaction.spans?.any { span -> span.op == "child" } == true + } + + // Verify we received the loop messages (should be 10 of them) + var loopMessageCount = 0 + try { + for (i in 0..9) { + testHelper.ensureErrorReceived { event -> + val matches = + event.message?.message?.contains("items we'll wait to flush to Sentry!") == true + if (matches) loopMessageCount++ + matches + } + } + } catch (e: Exception) { + // Some loop messages might be missing, but we should have at least some + } + + assertTrue( + "Should receive at least 5 loop messages, got $loopMessageCount", + loopMessageCount >= 5, + ) + + // Verify we have breadcrumbs + testHelper.ensureErrorReceived { event -> + event.breadcrumbs?.any { breadcrumb -> + breadcrumb.message?.contains("Processed by") == true + } == true + } + } +} diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java index 16f5a09d1b4..7ee46649e97 100644 --- a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java @@ -66,8 +66,8 @@ public static void main(String[] args) throws InterruptedException { // Enable SDK logging with Debug level options.setDebug(true); // To change the verbosity, use: - // options.setDiagnosticLevel(SentryLevel.ERROR); // By default it's DEBUG. + // options.setDiagnosticLevel(SentryLevel.ERROR); // A good option to have SDK debug log in prod is to use only level ERROR here. // Exclude frames from some packages from being "inApp" so are hidden by default in Sentry @@ -82,15 +82,16 @@ public static void main(String[] args) throws InterruptedException { options.setTracesSampleRate(1.0); // set 0.5 to send 50% of traces // Determine traces sample rate based on the sampling context - // options.setTracesSampler( - // context -> { - // // only 10% of transactions with "/product" prefix will be collected - // if (!context.getTransactionContext().getName().startsWith("/products")) { - // return 0.1; - // } else { - // return 0.5; - // } - // }); + // options.setTracesSampler( + // context -> { + // // only 10% of transactions with "/product" prefix will be collected + // if (!context.getTransactionContext().getName().startsWith("/products")) + // { + // return 0.1; + // } else { + // return 0.5; + // } + // }); }); Sentry.addBreadcrumb( diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index 3e2653f717a..b32583074d4 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -576,7 +576,8 @@ public final class io/sentry/systemtest/util/TestHelper { public final fun getJsonSerializer ()Lio/sentry/JsonSerializer; public final fun getRestClient ()Lio/sentry/systemtest/util/RestTestClient; public final fun getSentryClient ()Lio/sentry/systemtest/util/SentryMockServerClient; - public final fun launch (Ljava/io/File;Ljava/util/Map;)Ljava/lang/Process; + public final fun launch (Ljava/io/File;Ljava/util/Map;Z)Ljava/lang/Process; + public static synthetic fun launch$default (Lio/sentry/systemtest/util/TestHelper;Ljava/io/File;Ljava/util/Map;ZILjava/lang/Object;)Ljava/lang/Process; public final fun logObject (Ljava/lang/Object;)V public final fun reset ()V public final fun setEnvelopeCounts (Lio/sentry/systemtest/util/EnvelopeCounts;)V diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 56c699e995e..73b2acd2ae1 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -288,9 +288,18 @@ class TestHelper(backendUrl: String) { return jarFiles.maxOf { it } } - fun launch(jar: File, env: Map): Process { + fun launch(jar: File, env: Map, enableOtelAutoConfig: Boolean = false): Process { + val processBuilderList = mutableListOf("java", "--add-opens", "java.base/java.lang=ALL-UNNAMED") + + if (enableOtelAutoConfig) { + processBuilderList.add("-Dotel.java.global-autoconfigure.enabled=true") + } + + processBuilderList.add("-jar") + processBuilderList.add(jar.absolutePath) + val processBuilder = - ProcessBuilder("java", "-jar", jar.absolutePath).inheritIO() // forward i/o to current process + ProcessBuilder(processBuilderList).inheritIO() // forward i/o to current process processBuilder.environment().putAll(env) diff --git a/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java b/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java index 3d5bccc3d7b..9a70e31cee6 100644 --- a/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java +++ b/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java @@ -32,7 +32,8 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions Objects.requireNonNull(options, "SentryOptions is required"); if (options.isEnableShutdownHook()) { - thread = new Thread(() -> scopes.flush(options.getFlushTimeoutMillis())); + thread = + new Thread(() -> scopes.flush(options.getFlushTimeoutMillis()), "sentry-shutdownhook"); handleShutdownInProgress( () -> { runtime.addShutdownHook(thread); diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index 5f5162dac93..e6f1be3d952 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -174,20 +174,23 @@ public void close(final boolean isRestarting) throws IOException { executor.shutdown(); options.getLogger().log(SentryLevel.DEBUG, "Shutting down"); try { - // We need a small timeout to be able to save to disk any rejected envelope - long timeout = isRestarting ? 0 : options.getFlushTimeoutMillis(); - if (!executor.awaitTermination(timeout, TimeUnit.MILLISECONDS)) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Failed to shutdown the async connection async sender within " - + timeout - + " ms. Trying to force it now."); - executor.shutdownNow(); - if (currentRunnable != null) { - // We store to disk any envelope that is currently being sent - executor.getRejectedExecutionHandler().rejectedExecution(currentRunnable, executor); + // only stop sending events on a real shutdown, not on a restart + if (!isRestarting) { + // We need a small timeout to be able to save to disk any rejected envelope + long timeout = options.getFlushTimeoutMillis(); + if (!executor.awaitTermination(timeout, TimeUnit.MILLISECONDS)) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to shutdown the async connection async sender within " + + timeout + + " ms. Trying to force it now."); + executor.shutdownNow(); + if (currentRunnable != null) { + // We store to disk any envelope that is currently being sent + executor.getRejectedExecutionHandler().rejectedExecution(currentRunnable, executor); + } } } } catch (InterruptedException e) { diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index c7ed2b46aa8..c3a7f2ce04f 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -352,16 +352,22 @@ class AsyncHttpTransportTest { } @Test - fun `close with isRestarting true does not await termination`() { - fixture.sentryOptions.flushTimeoutMillis = 123 + fun `close shuts down the executor and runs executing runnable through rejectedExecutionHandler`() { + val rejectedExecutionHandler = mock() val sut = fixture.getSUT() - sut.close(true) + val runnable = mock() - verify(fixture.executor).awaitTermination(eq(0), eq(TimeUnit.MILLISECONDS)) + // Emulate a runnable currently being executed + sut.injectForField("currentRunnable", runnable) + whenever(fixture.executor.rejectedExecutionHandler).thenReturn(rejectedExecutionHandler) + sut.close(false) + + verify(fixture.executor).shutdownNow() + verify(rejectedExecutionHandler).rejectedExecution(eq(runnable), eq(fixture.executor)) } @Test - fun `close shuts down the executor and runs executing runnable through rejectedExecutionHandler`() { + fun `does not shut down executor immediately on restart`() { val rejectedExecutionHandler = mock() val sut = fixture.getSUT() val runnable = mock() @@ -371,8 +377,9 @@ class AsyncHttpTransportTest { whenever(fixture.executor.rejectedExecutionHandler).thenReturn(rejectedExecutionHandler) sut.close(true) - verify(fixture.executor).shutdownNow() - verify(rejectedExecutionHandler).rejectedExecution(eq(runnable), eq(fixture.executor)) + verify(fixture.executor).shutdown() + verify(fixture.executor, never()).shutdownNow() + verify(rejectedExecutionHandler, never()).rejectedExecution(eq(runnable), eq(fixture.executor)) } @Test diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 619e6bda104..fda5042c819 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -587,6 +587,7 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-console", "false", "true", "false"), + ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), ] def _find_module_number(self, module_name: str, agent: str, auto_init: str) -> int: From b4351697e4aedf6a54b0fb9ed4c89882664a4b23 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 30 Jul 2025 14:34:45 +0000 Subject: [PATCH 03/21] release: 8.18.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85dc62b5559..29f989ca0e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.18.0 ### Features diff --git a/gradle.properties b/gradle.properties index cac0c419ae9..b40983f3ffe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled android.useAndroidX=true # Release information -versionName=8.17.0 +versionName=8.18.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From c1cee9b7108f5ed8348b8a88c2f1fe145d43c37b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 30 Jul 2025 23:26:19 +0200 Subject: [PATCH 04/21] ref(replay): Use main thread to schedule capture (#4542) --- CHANGELOG.md | 6 + .../android/replay/ScreenshotRecorder.kt | 174 +++++++++--------- .../sentry/android/replay/WindowRecorder.kt | 154 +++++++++++----- .../android/replay/util/MainLooperHandler.kt | 12 +- .../sentry/android/replay/ReplaySmokeTest.kt | 16 +- 5 files changed, 229 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f989ca0e0..456e6eb2fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improvements + +- Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) + ## 8.18.0 ### Features diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 618f25fb782..2d866e6a6db 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -60,6 +60,9 @@ internal class ScreenshotRecorder( private val debugOverlayDrawable = DebugOverlayDrawable() fun capture() { + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Capturing screenshot, isCapturing: %s", isCapturing.get()) + } if (!isCapturing.get()) { if (options.sessionReplay.isDebug) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") @@ -67,6 +70,15 @@ internal class ScreenshotRecorder( return } + if (options.sessionReplay.isDebug) { + options.logger.log( + DEBUG, + "Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s", + contentChanged.get(), + lastCaptureSuccessful.get(), + ) + } + if (!contentChanged.get() && lastCaptureSuccessful.get()) { screenshotRecorderCallback?.onScreenshotRecorded(screenshot) return @@ -84,99 +96,95 @@ internal class ScreenshotRecorder( return } - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible - mainLooperHandler.post { - try { - contentChanged.set(false) - PixelCopy.request( - window, - screenshot, - { copyResult: Int -> - if (copyResult != PixelCopy.SUCCESS) { - options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - lastCaptureSuccessful.set(false) - return@request - } - - // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times - // in a row, we should capture) - if (contentChanged.get()) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - lastCaptureSuccessful.set(false) - return@request - } + try { + contentChanged.set(false) + PixelCopy.request( + window, + screenshot, + { copyResult: Int -> + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + lastCaptureSuccessful.set(false) + return@request + } + + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times + // in a row, we should capture) + if (contentChanged.get()) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + lastCaptureSuccessful.set(false) + return@request + } + + // TODO: disableAllMasking here and dont traverse? + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + recorder.submitSafely(options, "screenshot_recorder.mask") { + val debugMasks = mutableListOf() + + val canvas = Canvas(screenshot) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { node -> + if (node.shouldMask && (node.width > 0 && node.height > 0)) { + node.visibleRect ?: return@traverse false + + // TODO: investigate why it returns true on RN when it shouldn't + // if (viewHierarchy.isObscured(node)) { + // return@traverse true + // } + + val (visibleRects, color) = + when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect) + } - // TODO: disableAllMasking here and dont traverse? - val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy, options) - - recorder.submitSafely(options, "screenshot_recorder.mask") { - val debugMasks = mutableListOf() - - val canvas = Canvas(screenshot) - canvas.setMatrix(prescaledMatrix) - viewHierarchy.traverse { node -> - if (node.shouldMask && (node.width > 0 && node.height > 0)) { - node.visibleRect ?: return@traverse false - - // TODO: investigate why it returns true on RN when it shouldn't - // if (viewHierarchy.isObscured(node)) { - // return@traverse true - // } - - val (visibleRects, color) = - when (node) { - is ImageViewHierarchyNode -> { - listOf(node.visibleRect) to - screenshot.dominantColorForRect(node.visibleRect) - } - - is TextViewHierarchyNode -> { - val textColor = - node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK - node.layout.getVisibleRects( - node.visibleRect, - node.paddingLeft, - node.paddingTop, - ) to textColor - } - - else -> { - listOf(node.visibleRect) to Color.BLACK - } + is TextViewHierarchyNode -> { + val textColor = + node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop, + ) to textColor } - maskingPaint.setColor(color) - visibleRects.forEach { rect -> - canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) - } - if (options.replayController.isDebugMaskingOverlayEnabled()) { - debugMasks.addAll(visibleRects) + else -> { + listOf(node.visibleRect) to Color.BLACK + } } + + maskingPaint.setColor(color) + visibleRects.forEach { rect -> + canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) + } + if (options.replayController.isDebugMaskingOverlayEnabled()) { + debugMasks.addAll(visibleRects) } - return@traverse true } + return@traverse true + } - if (options.replayController.isDebugMaskingOverlayEnabled()) { - mainLooperHandler.post { - if (debugOverlayDrawable.callback == null) { - root.overlay.add(debugOverlayDrawable) - } - debugOverlayDrawable.updateMasks(debugMasks) - root.postInvalidate() + if (options.replayController.isDebugMaskingOverlayEnabled()) { + mainLooperHandler.post { + if (debugOverlayDrawable.callback == null) { + root.overlay.add(debugOverlayDrawable) } + debugOverlayDrawable.updateMasks(debugMasks) + root.postInvalidate() } - screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastCaptureSuccessful.set(true) - contentChanged.set(false) } - }, - mainLooperHandler.handler, - ) - } catch (e: Throwable) { - options.logger.log(WARNING, "Failed to capture replay recording", e) - lastCaptureSuccessful.set(false) - } + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastCaptureSuccessful.set(true) + contentChanged.set(false) + } + }, + mainLooperHandler.handler, + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + lastCaptureSuccessful.set(false) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 7039389427b..5731c4e4f56 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -4,20 +4,17 @@ import android.annotation.TargetApi import android.graphics.Point import android.view.View import android.view.ViewTreeObserver +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnPreDrawListenerSafe -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.hasSize import io.sentry.android.replay.util.removeOnPreDrawListenerSafe -import io.sentry.android.replay.util.scheduleAtFixedRateSafely import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean @TargetApi(26) @@ -36,25 +33,93 @@ internal class WindowRecorder( private val rootViews = ArrayList>() private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() - private var recorder: ScreenshotRecorder? = null - private var capturingTask: ScheduledFuture<*>? = null - private val capturer by lazy { - Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + private val capturerLock = AutoClosableReentrantLock() + @Volatile private var capturer: Capturer? = null + + private class Capturer( + private val options: SentryOptions, + private val mainLooperHandler: MainLooperHandler, + ) : Runnable { + + var recorder: ScreenshotRecorder? = null + var config: ScreenshotRecorderConfig? = null + private val isRecording = AtomicBoolean(true) + + fun resume() { + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Resuming the capture runnable.") + } + recorder?.resume() + isRecording.getAndSet(true) + // Remove any existing callbacks to prevent concurrent capture loops + mainLooperHandler.removeCallbacks(this) + val posted = mainLooperHandler.post(this) + if (!posted) { + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is not ready.", + ) + } + } + + fun pause() { + recorder?.pause() + isRecording.getAndSet(false) + } + + fun stop() { + recorder?.close() + recorder = null + isRecording.getAndSet(false) + } + + override fun run() { + // protection against the case where the capture is executed after the recording has stopped + if (!isRecording.get()) { + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Not capturing frames, recording is not running.") + } + return + } + + try { + if (options.sessionReplay.isDebug) { + options.logger.log(DEBUG, "Capturing a frame.") + } + recorder?.capture() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to capture a frame", e) + } + + if (options.sessionReplay.isDebug) { + options.logger.log( + DEBUG, + "Posting the capture runnable again, frame rate is ${config?.frameRate ?: 1} fps.", + ) + } + val posted = mainLooperHandler.postDelayed(this, 1000L / (config?.frameRate ?: 1)) + if (!posted) { + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is shutting down.", + ) + } + } } override fun onRootViewsChanged(root: View, added: Boolean) { rootViewsLock.acquire().use { if (added) { rootViews.add(WeakReference(root)) - recorder?.bind(root) + capturer?.recorder?.bind(root) determineWindowSize(root) } else { - recorder?.unbind(root) + capturer?.recorder?.unbind(root) rootViews.removeAll { it.get() == root } val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null && root != newRoot) { - recorder?.bind(newRoot) + capturer?.recorder?.bind(newRoot) determineWindowSize(newRoot) } else { Unit // synchronized block wants us to return something lol @@ -102,7 +167,17 @@ internal class WindowRecorder( return } - recorder = + if (capturer == null) { + capturerLock.acquire().use { + if (capturer == null) { + // don't recreate runnable for every config change, just update the config + capturer = Capturer(options, mainLooperHandler) + } + } + } + + capturer?.config = config + capturer?.recorder = ScreenshotRecorder( config, options, @@ -113,59 +188,50 @@ internal class WindowRecorder( val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null) { - recorder?.bind(newRoot) + capturer?.recorder?.bind(newRoot) } - // TODO: change this to use MainThreadHandler and just post on the main thread with delay - // to avoid thread context switch every time - capturingTask = - capturer.scheduleAtFixedRateSafely( - options, - "$TAG.capture", + + // Remove any existing callbacks to prevent concurrent capture loops + mainLooperHandler.removeCallbacks(capturer) + + val posted = + mainLooperHandler.postDelayed( + capturer, 100L, // delay the first run by a bit, to allow root view listener to register - 1000L / config.frameRate, - MILLISECONDS, - ) { - recorder?.capture() - } + ) + if (!posted) { + options.logger.log( + WARNING, + "Failed to post the capture runnable, main looper is shutting down.", + ) + } } override fun resume() { - recorder?.resume() + capturer?.resume() } override fun pause() { - recorder?.pause() + capturer?.pause() } override fun reset() { lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { - rootViews.forEach { recorder?.unbind(it.get()) } + rootViews.forEach { capturer?.recorder?.unbind(it.get()) } rootViews.clear() } } override fun stop() { - recorder?.close() - recorder = null - capturingTask?.cancel(false) - capturingTask = null + capturer?.stop() + capturerLock.acquire().use { capturer = null } isRecording.set(false) } override fun close() { reset() + mainLooperHandler.removeCallbacks(capturer) stop() - capturer.gracefullyShutdown(options) - } - - private class RecorderExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryWindowRecorder-" + cnt++) - ret.setDaemon(true) - return ret - } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt index 7c067111e7e..691cce03a78 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt @@ -6,7 +6,15 @@ import android.os.Looper internal class MainLooperHandler(looper: Looper = Looper.getMainLooper()) { val handler = Handler(looper) - fun post(runnable: Runnable) { - handler.post(runnable) + fun post(runnable: Runnable): Boolean { + return handler.post(runnable) + } + + fun postDelayed(runnable: Runnable?, delay: Long): Boolean { + return handler.postDelayed(runnable ?: return false, delay) + } + + fun removeCallbacks(runnable: Runnable?) { + handler.removeCallbacks(runnable ?: return) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 0b3b8097d9c..c26e6be9c41 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -24,6 +24,7 @@ import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import java.time.Duration +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest @@ -35,6 +36,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check @@ -65,7 +67,6 @@ class ReplaySmokeTest { .whenever(it) .configureScope(any()) } - var count: Int = 0 private class ImmediateHandler : Handler( @@ -75,6 +76,8 @@ class ReplaySmokeTest { } ) + private val recordingThread = Executors.newSingleThreadScheduledExecutor() + fun getSut( context: Context, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -88,9 +91,14 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { - (it.arguments[0] as Runnable).run() - count++ + whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.postDelayed(any(), anyLong())).then { + // have to use another thread here otherwise it will block the test thread + recordingThread.schedule( + it.arguments[0] as Runnable, + it.arguments[1] as Long, + TimeUnit.MILLISECONDS, + ) } }, ) From 59250c44bbc1bc164b5b8569c49c9965440fbe5d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 30 Jul 2025 23:52:25 +0200 Subject: [PATCH 05/21] perf(connectivity): Cache network capabilities and status to reduce IPC calls (#4560) --- CHANGELOG.md | 4 + .../core/AndroidOptionsInitializer.java | 4 +- .../util/AndroidConnectionStatusProvider.java | 452 ++++++++++++--- .../AndroidConnectionStatusProviderTest.kt | 515 ++++++++++++++---- .../core/NetworkBreadcrumbsIntegrationTest.kt | 21 +- sentry/api/sentry.api | 3 +- .../io/sentry/IConnectionStatusProvider.java | 3 +- .../sentry/NoOpConnectionStatusProvider.java | 6 + sentry/src/main/java/io/sentry/Scopes.java | 1 + .../test/java/io/sentry/SentryOptionsTest.kt | 2 + 10 files changed, 847 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 456e6eb2fb2..2f7fa0aae2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) +### Fixes + +- Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) + ## 8.18.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 8a564081ea0..04d927ff79e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -28,6 +28,7 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; @@ -157,7 +158,8 @@ static void initializeIntegrationsAndProcessors( if (options.getConnectionStatusProvider() instanceof NoOpConnectionStatusProvider) { options.setConnectionStatusProvider( - new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); + new AndroidConnectionStatusProvider( + context, options, buildInfoProvider, AndroidCurrentDateProvider.getInstance())); } if (options.getCacheDirPath() != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index ed8948e0a5a..6308f704009 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -8,12 +8,15 @@ import android.net.Network; import android.net.NetworkCapabilities; import android.os.Build; +import androidx.annotation.NonNull; import io.sentry.IConnectionStatusProvider; import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.List; @@ -31,104 +34,406 @@ public final class AndroidConnectionStatusProvider implements IConnectionStatusProvider { private final @NotNull Context context; - private final @NotNull ILogger logger; + private final @NotNull SentryOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull ICurrentDateProvider timeProvider; private final @NotNull List connectionStatusObservers; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private volatile @Nullable NetworkCallback networkCallback; + private static final @NotNull AutoClosableReentrantLock connectivityManagerLock = + new AutoClosableReentrantLock(); + private static volatile @Nullable ConnectivityManager connectivityManager; + + private static final int[] transports = { + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH + }; + + private static final int[] capabilities = new int[2]; + + private final @NotNull Thread initThread; + private volatile @Nullable NetworkCapabilities cachedNetworkCapabilities; + private volatile @Nullable Network currentNetwork; + private volatile long lastCacheUpdateTime = 0; + private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes + + @SuppressLint("InlinedApi") public AndroidConnectionStatusProvider( @NotNull Context context, - @NotNull ILogger logger, - @NotNull BuildInfoProvider buildInfoProvider) { + @NotNull SentryOptions options, + @NotNull BuildInfoProvider buildInfoProvider, + @NotNull ICurrentDateProvider timeProvider) { this.context = ContextUtils.getApplicationContext(context); - this.logger = logger; + this.options = options; this.buildInfoProvider = buildInfoProvider; + this.timeProvider = timeProvider; this.connectionStatusObservers = new ArrayList<>(); + + capabilities[0] = NetworkCapabilities.NET_CAPABILITY_INTERNET; + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + capabilities[1] = NetworkCapabilities.NET_CAPABILITY_VALIDATED; + } + + // Register network callback immediately for caching + //noinspection Convert2MethodRef + initThread = new Thread(() -> ensureNetworkCallbackRegistered()); + initThread.start(); } - @Override - public @NotNull ConnectionStatus getConnectionStatus() { - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return ConnectionStatus.UNKNOWN; + /** + * Enhanced network connectivity check for Android 15. Checks for NET_CAPABILITY_INTERNET, + * NET_CAPABILITY_VALIDATED, and proper transport types. + * https://medium.com/@doronkakuli/adapting-your-network-connectivity-checks-for-android-15-a-practical-guide-2b1850619294 + */ + @SuppressLint("InlinedApi") + private boolean isNetworkEffectivelyConnected( + final @Nullable NetworkCapabilities networkCapabilities) { + if (networkCapabilities == null) { + return false; + } + + // Check for general internet capability AND validated status + boolean hasInternetAndValidated = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + hasInternetAndValidated = + hasInternetAndValidated + && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); } - return getConnectionStatus(context, connectivityManager, logger); - // getActiveNetworkInfo might return null if VPN doesn't specify its - // underlying network - // when min. API 24, use: - // connectivityManager.registerDefaultNetworkCallback(...) + if (!hasInternetAndValidated) { + return false; + } + + // Additionally, ensure it's a recognized transport type for general internet access + for (final int transport : transports) { + if (networkCapabilities.hasTransport(transport)) { + return true; + } + } + return false; } - @Override - public @Nullable String getConnectionType() { - return getConnectionType(context, logger, buildInfoProvider); + /** Get connection status from cached NetworkCapabilities or fallback to legacy method. */ + private @NotNull ConnectionStatus getConnectionStatusFromCache() { + if (cachedNetworkCapabilities != null) { + return isNetworkEffectivelyConnected(cachedNetworkCapabilities) + ? ConnectionStatus.CONNECTED + : ConnectionStatus.DISCONNECTED; + } + + // Fallback to legacy method when NetworkCapabilities not available + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + return getConnectionStatus(context, connectivityManager, options.getLogger()); + } + + return ConnectionStatus.UNKNOWN; } - @Override - public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - connectionStatusObservers.add(observer); + /** Get connection type from cached NetworkCapabilities or fallback to legacy method. */ + private @Nullable String getConnectionTypeFromCache() { + final NetworkCapabilities capabilities = cachedNetworkCapabilities; + if (capabilities != null) { + return getConnectionType(capabilities); } - if (networkCallback == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (networkCallback == null) { - final @NotNull NetworkCallback newNetworkCallback = - new NetworkCallback() { - @Override - public void onAvailable(final @NotNull Network network) { - updateObservers(); - } + // Fallback to legacy method when NetworkCapabilities not available + return getConnectionType(context, options.getLogger(), buildInfoProvider); + } - @Override - public void onUnavailable() { - updateObservers(); - } + private void ensureNetworkCallbackRegistered() { + if (networkCallback != null) { + return; // Already registered + } - @Override - public void onLost(final @NotNull Network network) { - updateObservers(); - } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback != null) { + return; + } - public void updateObservers() { - final @NotNull ConnectionStatus status = getConnectionStatus(); - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - for (final @NotNull IConnectionStatusObserver observer : - connectionStatusObservers) { - observer.onConnectionStatusChanged(status); - } + final @NotNull NetworkCallback callback = + new NetworkCallback() { + @Override + public void onAvailable(final @NotNull Network network) { + currentNetwork = network; + } + + @Override + public void onUnavailable() { + clearCacheAndNotifyObservers(); + } + + @Override + public void onLost(final @NotNull Network network) { + if (!network.equals(currentNetwork)) { + return; + } + clearCacheAndNotifyObservers(); + } + + private void clearCacheAndNotifyObservers() { + // Clear cached capabilities and network reference atomically + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log(SentryLevel.DEBUG, "Cache cleared - network lost/unavailable"); + + // Notify all observers with DISCONNECTED status directly + // No need to query ConnectivityManager - we know the network is gone + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(ConnectionStatus.DISCONNECTED); + } + } + } + + @Override + public void onCapabilitiesChanged( + @NonNull Network network, @NonNull NetworkCapabilities networkCapabilities) { + if (!network.equals(currentNetwork)) { + return; + } + updateCacheAndNotifyObservers(network, networkCapabilities); + } + + private void updateCacheAndNotifyObservers( + @Nullable Network network, @Nullable NetworkCapabilities networkCapabilities) { + // Check if this change is meaningful before notifying observers + final boolean shouldUpdate = isSignificantChange(networkCapabilities); + + // Only notify observers if something meaningful changed + if (shouldUpdate) { + updateCache(networkCapabilities); + + final @NotNull ConnectionStatus status = getConnectionStatusFromCache(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(status); } } - }; + } + } + + /** + * Check if NetworkCapabilities change is significant for our observers. Only notify for + * changes that affect connectivity status or connection type. + */ + private boolean isSignificantChange(@Nullable NetworkCapabilities newCapabilities) { + final NetworkCapabilities oldCapabilities = cachedNetworkCapabilities; + + // Always significant if transitioning between null and non-null + if ((oldCapabilities == null) != (newCapabilities == null)) { + return true; + } + + // If both null, no change + if (oldCapabilities == null && newCapabilities == null) { + return false; + } + + // Check significant capability changes + if (hasSignificantCapabilityChanges(oldCapabilities, newCapabilities)) { + return true; + } + + // Check significant transport changes + if (hasSignificantTransportChanges(oldCapabilities, newCapabilities)) { + return true; + } + + return false; + } + + /** Check if capabilities that affect connectivity status changed. */ + private boolean hasSignificantCapabilityChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check capabilities we care about for connectivity determination + for (int capability : capabilities) { + if (capability != 0 + && old.hasCapability(capability) != new_.hasCapability(capability)) { + return true; + } + } + + return false; + } + + /** Check if transport types that affect connection type changed. */ + private boolean hasSignificantTransportChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check transports we care about for connection type determination + for (int transport : transports) { + if (old.hasTransport(transport) != new_.hasTransport(transport)) { + return true; + } + } + + return false; + } + }; + + if (registerNetworkCallback(context, options.getLogger(), buildInfoProvider, callback)) { + networkCallback = callback; + options.getLogger().log(SentryLevel.DEBUG, "Network callback registered successfully"); + } else { + options.getLogger().log(SentryLevel.WARNING, "Failed to register network callback"); + } + } + } + + @SuppressLint({"NewApi", "MissingPermission"}) + private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + try { + if (networkCapabilities != null) { + cachedNetworkCapabilities = networkCapabilities; + } else { + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + options + .getLogger() + .log( + SentryLevel.INFO, + "No permission (ACCESS_NETWORK_STATE) to check network status."); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) { + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + // Fallback: query current active network + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + final Network activeNetwork = connectivityManager.getActiveNetwork(); - if (registerNetworkCallback(context, logger, buildInfoProvider, newNetworkCallback)) { - networkCallback = newNetworkCallback; - return true; + cachedNetworkCapabilities = + activeNetwork != null + ? connectivityManager.getNetworkCapabilities(activeNetwork) + : null; } else { - return false; + cachedNetworkCapabilities = + null; // Clear cached capabilities if connectivity manager is null } } + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Cache updated - Status: " + + getConnectionStatusFromCache() + + ", Type: " + + getConnectionTypeFromCache()); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); } } - // networkCallback is already registered, so we can safely return true - return true; + } + + private boolean isCacheValid() { + return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS; + } + + @Override + public @NotNull ConnectionStatus getConnectionStatus() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionStatusFromCache(); + } + + @Override + public @Nullable String getConnectionType() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionTypeFromCache(); + } + + @Override + public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.add(observer); + } + ensureNetworkCallbackRegistered(); + + // Network callback is already registered during initialization + return networkCallback != null; } @Override public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { connectionStatusObservers.remove(observer); - if (connectionStatusObservers.isEmpty()) { - if (networkCallback != null) { - unregisterNetworkCallback(context, logger, networkCallback); - networkCallback = null; - } - } + // Keep the callback registered for caching even if no observers } } + /** Clean up resources - should be called when the provider is no longer needed */ + @Override + public void close() { + try { + options + .getExecutorService() + .submit( + () -> { + final NetworkCallback callbackRef; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.clear(); + + callbackRef = networkCallback; + networkCallback = null; + + if (callbackRef != null) { + unregisterNetworkCallback(context, options.getLogger(), callbackRef); + } + // Clear cached state + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = 0; + } + try (final @NotNull ISentryLifecycleToken ignored = + connectivityManagerLock.acquire()) { + connectivityManager = null; + } + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error submitting AndroidConnectionStatusProvider task", t); + } + } + + /** + * Get the cached NetworkCapabilities for advanced use cases. Returns null if cache is stale or no + * capabilities are available. + * + * @return cached NetworkCapabilities or null + */ + @TestOnly + @Nullable + public NetworkCapabilities getCachedNetworkCapabilities() { + return cachedNetworkCapabilities; + } + /** * Return the Connection status * @@ -295,12 +600,22 @@ public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObser private static @Nullable ConnectivityManager getConnectivityManager( final @NotNull Context context, final @NotNull ILogger logger) { - final ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivityManager == null) { - logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); + if (connectivityManager != null) { + return connectivityManager; + } + + try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { + if (connectivityManager != null) { + return connectivityManager; // Double-checked locking + } + + connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) { + logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); + } + return connectivityManager; } - return connectivityManager; } @SuppressLint({"MissingPermission", "NewApi"}) @@ -358,4 +673,15 @@ public List getStatusObservers() { public NetworkCallback getNetworkCallback() { return networkCallback; } + + @TestOnly + @NotNull + public Thread getInitThread() { + return initThread; + } + + @TestOnly + public static void setConnectivityManager(final @Nullable ConnectivityManager cm) { + connectivityManager = cm; + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt index b15ab4e605d..a362885d635 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt @@ -1,18 +1,27 @@ package io.sentry.android.core +import android.Manifest import android.content.Context import android.content.pm.PackageManager.PERMISSION_DENIED +import android.content.pm.PackageManager.PERMISSION_GRANTED import android.net.ConnectivityManager import android.net.ConnectivityManager.NetworkCallback import android.net.Network import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED import android.net.NetworkCapabilities.TRANSPORT_CELLULAR import android.net.NetworkCapabilities.TRANSPORT_ETHERNET import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkInfo import android.os.Build import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.SentryOptions import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider +import io.sentry.test.ImmediateExecutorService +import io.sentry.transport.ICurrentDateProvider +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -21,11 +30,13 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never +import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class AndroidConnectionStatusProviderTest { @@ -34,14 +45,24 @@ class AndroidConnectionStatusProviderTest { private lateinit var connectivityManager: ConnectivityManager private lateinit var networkInfo: NetworkInfo private lateinit var buildInfo: BuildInfoProvider + private lateinit var timeProvider: ICurrentDateProvider + private lateinit var options: SentryOptions private lateinit var network: Network private lateinit var networkCapabilities: NetworkCapabilities + private lateinit var logger: ILogger + + private var currentTime = 1000L @BeforeTest fun beforeTest() { contextMock = mock() connectivityManager = mock() - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) + whenever(contextMock.getSystemService(Context.CONNECTIVITY_SERVICE)) + .thenReturn(connectivityManager) + whenever( + contextMock.checkPermission(eq(Manifest.permission.ACCESS_NETWORK_STATE), any(), any()) + ) + .thenReturn(PERMISSION_GRANTED) networkInfo = mock() whenever(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) @@ -54,13 +75,38 @@ class AndroidConnectionStatusProviderTest { networkCapabilities = mock() whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(networkCapabilities.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + + timeProvider = mock() + whenever(timeProvider.currentTimeMillis).thenAnswer { currentTime } + + logger = mock() + options = SentryOptions() + options.setLogger(logger) + options.executorService = ImmediateExecutorService() + + // Reset current time for each test to ensure cache isolation + currentTime = 1000L - connectionStatusProvider = AndroidConnectionStatusProvider(contextMock, mock(), buildInfo) + connectionStatusProvider = + AndroidConnectionStatusProvider(contextMock, options, buildInfo, timeProvider) + + // Wait for async callback registration to complete + connectionStatusProvider.initThread.join() + } + + @AfterTest + fun `tear down`() { + // clear the cache and ensure proper cleanup + connectionStatusProvider.close() } @Test fun `When network is active and connected with permission, return CONNECTED for isConnected`() { whenever(networkInfo.isConnected).thenReturn(true) + assertEquals( IConnectionStatusProvider.ConnectionStatus.CONNECTED, connectionStatusProvider.connectionStatus, @@ -89,6 +135,8 @@ class AndroidConnectionStatusProviderTest { @Test fun `When network is not active, return DISCONNECTED for isConnected`() { + whenever(connectivityManager.activeNetwork).thenReturn(null) + assertEquals( IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, connectionStatusProvider.connectionStatus, @@ -97,98 +145,86 @@ class AndroidConnectionStatusProviderTest { @Test fun `When ConnectivityManager is not available, return UNKNOWN for isConnected`() { - whenever(contextMock.getSystemService(any())).thenReturn(null) + // First close the existing provider to clean up static state + connectionStatusProvider.close() + + // Create a fresh context mock that returns null for ConnectivityManager + val nullConnectivityContext = mock() + whenever(nullConnectivityContext.getSystemService(any())).thenReturn(null) + whenever( + nullConnectivityContext.checkPermission( + eq(Manifest.permission.ACCESS_NETWORK_STATE), + any(), + any(), + ) + ) + .thenReturn(PERMISSION_GRANTED) + + // Create a new provider with the null connectivity manager + val providerWithNullConnectivity = + AndroidConnectionStatusProvider(nullConnectivityContext, options, buildInfo, timeProvider) + providerWithNullConnectivity.initThread.join() // Wait for async init to complete + assertEquals( IConnectionStatusProvider.ConnectionStatus.UNKNOWN, - connectionStatusProvider.connectionStatus, + providerWithNullConnectivity.connectionStatus, ) + + providerWithNullConnectivity.close() } @Test fun `When ConnectivityManager is not available, return null for getConnectionType`() { - assertNull(AndroidConnectionStatusProvider.getConnectionType(mock(), mock(), buildInfo)) + whenever(contextMock.getSystemService(any())).thenReturn(null) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When there's no permission, return null for getConnectionType`() { whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network is not active, return null for getConnectionType`() { - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) + whenever(connectivityManager.activeNetwork).thenReturn(null) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network capabilities are not available, return null for getConnectionType`() { - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(null) + + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_WIFI, return wifi`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(true) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) - assertEquals( - "wifi", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) + assertEquals("wifi", connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_ETHERNET, return ethernet`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(true) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(false) - assertEquals( - "ethernet", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) + assertEquals("ethernet", connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_CELLULAR, return cellular`() { + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(true) - assertEquals( - "cellular", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) - } - - @Test - fun `When there's no permission, do not register any NetworkCallback`() { - val buildInfo = mock() - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) - val registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - contextMock, - mock(), - buildInfo, - mock(), - ) - - assertFalse(registered) - verify(connectivityManager, never()).registerDefaultNetworkCallback(any()) - } - - @Test - fun `When sdkInfoVersion is not min N, do not register any NetworkCallback`() { - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - val registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - contextMock, - mock(), - buildInfo, - mock(), - ) - - assertFalse(registered) - verify(connectivityManager, never()).registerDefaultNetworkCallback(any()) + assertEquals("cellular", connectionStatusProvider.connectionType) } @Test @@ -199,7 +235,7 @@ class AndroidConnectionStatusProviderTest { val registered = AndroidConnectionStatusProvider.registerNetworkCallback( contextMock, - mock(), + logger, buildInfo, mock(), ) @@ -211,17 +247,16 @@ class AndroidConnectionStatusProviderTest { @Test fun `unregisterNetworkCallback calls connectivityManager unregisterDefaultNetworkCallback`() { whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, mock(), mock()) + AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, logger, mock()) verify(connectivityManager).unregisterNetworkCallback(any()) } @Test fun `When connectivityManager getActiveNetwork throws an exception, getConnectionType returns null`() { - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S) whenever(connectivityManager.activeNetwork).thenThrow(SecurityException("Android OS Bug")) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test @@ -231,27 +266,13 @@ class AndroidConnectionStatusProviderTest { assertFalse( AndroidConnectionStatusProvider.registerNetworkCallback( contextMock, - mock(), + logger, buildInfo, mock(), ) ) } - @Test - fun `When connectivityManager unregisterDefaultCallback throws an exception, it gets swallowed`() { - whenever(connectivityManager.registerDefaultNetworkCallback(any())) - .thenThrow(SecurityException("Android OS Bug")) - - var failed = false - try { - AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, mock(), mock()) - } catch (t: Throwable) { - failed = true - } - assertFalse(failed) - } - @Test fun `connectionStatus returns NO_PERMISSIONS when context does not hold the permission`() { whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) @@ -261,12 +282,6 @@ class AndroidConnectionStatusProviderTest { ) } - @Test - fun `connectionStatus returns ethernet when underlying mechanism provides ethernet`() { - whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(true) - assertEquals("ethernet", connectionStatusProvider.connectionType) - } - @Test fun `adding and removing an observer works correctly`() { whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) @@ -279,25 +294,341 @@ class AndroidConnectionStatusProviderTest { connectionStatusProvider.removeConnectionStatusObserver(observer) assertTrue(connectionStatusProvider.statusObservers.isEmpty()) - assertNull(connectionStatusProvider.networkCallback) } @Test - fun `underlying callbacks correctly trigger update`() { + fun `cache TTL works correctly`() { + // Setup: Mock network info to return connected + whenever(networkInfo.isConnected).thenReturn(true) + + // For API level M, the code uses getActiveNetwork() and getNetworkCapabilities() + // Let's track calls to these methods to verify caching behavior + + // Make the first call to establish baseline + val firstResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, firstResult) + + // Count how many times getActiveNetwork was called so far (includes any initialization calls) + val initialCallCount = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Advance time by 1 minute (less than 2 minute TTL) + currentTime += 60 * 1000L + + // Second call should use cache - no additional calls to getActiveNetwork + val secondResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, secondResult) + + val callCountAfterSecond = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Verify no additional calls were made (cache was used) + assertEquals(initialCallCount, callCountAfterSecond, "Second call should use cache") + + // Advance time beyond TTL (total 3 minutes) + currentTime += 2 * 60 * 1000L + + // Third call should refresh cache - should make new calls to getActiveNetwork + val thirdResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, thirdResult) + + val callCountAfterThird = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Verify that new calls were made (cache was refreshed) + assertTrue(callCountAfterThird > callCountAfterSecond, "Third call should refresh cache") + + // All results should be consistent + assertEquals(firstResult, secondResult) + assertEquals(secondResult, thirdResult) + } + + @Test + fun `observers are only notified for significant changes`() { whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + // Get the callback that was registered + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + // Create network capabilities for testing + val oldCaps = mock() + whenever(oldCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(oldCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(oldCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // First callback with capabilities - should notify + callback.onCapabilitiesChanged(network, oldCaps) + + // Second callback with same significant capabilities - should NOT notify additional times + callback.onCapabilitiesChanged(network, newCaps) + + // Only first change should trigger notification + verify(observer, times(1)).onConnectionStatusChanged(any()) + } - var callback: NetworkCallback? = null - whenever(connectivityManager.registerDefaultNetworkCallback(any())).then { invocation -> - callback = invocation.getArgument(0, NetworkCallback::class.java) as NetworkCallback - Unit - } + @Test + fun `observers are notified when significant capabilities change`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) val observer = mock() connectionStatusProvider.addConnectionStatusObserver(observer) - callback!!.onAvailable(mock()) - callback!!.onUnavailable() - callback!!.onLost(mock()) - connectionStatusProvider.removeConnectionStatusObserver(observer) - verify(observer, times(3)).onConnectionStatusChanged(any()) + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + val oldCaps = mock() + whenever(oldCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(oldCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(false) // Not validated + whenever(oldCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(oldCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) // Now validated + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + callback.onCapabilitiesChanged(network, oldCaps) + callback.onCapabilitiesChanged(network, newCaps) + + // Should be notified for both changes (validation state changed) + verify(observer, times(2)).onConnectionStatusChanged(any()) + } + + @Test + fun `observers are notified when transport changes`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + val wifiCaps = mock() + whenever(wifiCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(wifiCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(wifiCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(wifiCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(wifiCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val cellularCaps = mock() + whenever(cellularCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(cellularCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(cellularCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(false) + whenever(cellularCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(true) + whenever(cellularCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + callback.onCapabilitiesChanged(network, wifiCaps) + callback.onCapabilitiesChanged(network, cellularCaps) + + // Should be notified for both changes (transport changed) + verify(observer, times(2)).onConnectionStatusChanged(any()) + } + + @Test + fun `onLost clears cache and notifies with DISCONNECTED`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // Set current network + callback.onAvailable(network) + + // Lose the network + callback.onLost(network) + + assertNull(connectionStatusProvider.cachedNetworkCapabilities) + verify(observer) + .onConnectionStatusChanged(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + + @Test + fun `onUnavailable clears cache and notifies with DISCONNECTED`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + callback.onUnavailable() + + assertNull(connectionStatusProvider.cachedNetworkCapabilities) + verify(observer) + .onConnectionStatusChanged(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + + @Test + fun `onLost for different network is ignored`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + val network1 = mock() + val network2 = mock() + + // Set current network + callback.onAvailable(network1) + + // Lose a different network - should be ignored + callback.onLost(network2) + + verifyNoInteractions(observer) + } + + @Test + fun `isNetworkEffectivelyConnected works correctly for Android 15`() { + // Test case: has internet and validated capabilities with good transport + val goodCaps = mock() + whenever(goodCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(goodCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(goodCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(goodCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(goodCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // Override the mock to return good capabilities + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(goodCaps) + + // Force cache invalidation by advancing time beyond TTL + currentTime += 3 * 60 * 1000L // 3 minutes + + // Should return CONNECTED for good capabilities + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + + // Test case: missing validated capability + val unvalidatedCaps = mock() + whenever(unvalidatedCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(unvalidatedCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(false) + whenever(unvalidatedCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(unvalidatedCaps) + + // Force cache invalidation again + currentTime += 3 * 60 * 1000L + + assertEquals( + IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, + connectionStatusProvider.connectionStatus, + ) + } + + @Test + fun `API level below M falls back to legacy method`() { + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + whenever(networkInfo.isConnected).thenReturn(true) + + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + } + + @Test + fun `onCapabilitiesChanged updates cache`() { + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // Set network as current first + callback.onAvailable(network) + + // Create initial capabilities - CONNECTED state (wifi + validated) + val initialCaps = mock() + whenever(initialCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(initialCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(initialCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(initialCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(initialCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // First callback with initial capabilities + callback.onCapabilitiesChanged(network, initialCaps) + + // Verify cache contains the initial capabilities + assertEquals(initialCaps, connectionStatusProvider.cachedNetworkCapabilities) + + // Verify initial state - should be CONNECTED with wifi + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + assertEquals("wifi", connectionStatusProvider.connectionType) + + // Create new capabilities - DISCONNECTED state (cellular but not validated) + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)) + .thenReturn(false) // Not validated = DISCONNECTED + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // Second callback with changed capabilities + callback.onCapabilitiesChanged(network, newCaps) + + // Verify cache is updated with new capabilities + assertEquals(newCaps, connectionStatusProvider.cachedNetworkCapabilities) + + // Verify that subsequent calls use the updated cache + // Both connection status AND type should change + assertEquals( + IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, + connectionStatusProvider.connectionStatus, + ) + assertEquals("cellular", connectionStatusProvider.connectionType) + + // Verify observer was notified of the changes (both calls should notify since capabilities + // changed significantly) + verify(observer, times(2)).onConnectionStatusChanged(any()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index fd18e3d75b8..768fe87fbbd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -16,9 +16,12 @@ import io.sentry.SentryNanotimeDate import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback +import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -47,12 +50,6 @@ class NetworkBreadcrumbsIntegrationTest { var nowMs: Long = 0 val network = mock() - init { - whenever(mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))) - .thenReturn(connectivityManager) - } - fun getSut( enableNetworkEventBreadcrumbs: Boolean = true, buildInfo: BuildInfoProvider = mockBuildInfoProvider, @@ -73,6 +70,18 @@ class NetworkBreadcrumbsIntegrationTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + whenever(fixture.mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + whenever(fixture.context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))) + .thenReturn(fixture.connectivityManager) + } + + @AfterTest + fun `tear down`() { + AndroidConnectionStatusProvider.setConnectivityManager(null) + } + @Test fun `When network events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 37355206d37..0433872b299 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -738,7 +738,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun withScope (Lio/sentry/ScopeCallback;)V } -public abstract interface class io/sentry/IConnectionStatusProvider { +public abstract interface class io/sentry/IConnectionStatusProvider : java/io/Closeable { public abstract fun addConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)Z public abstract fun getConnectionStatus ()Lio/sentry/IConnectionStatusProvider$ConnectionStatus; public abstract fun getConnectionType ()Ljava/lang/String; @@ -1452,6 +1452,7 @@ public final class io/sentry/NoOpCompositePerformanceCollector : io/sentry/Compo public final class io/sentry/NoOpConnectionStatusProvider : io/sentry/IConnectionStatusProvider { public fun ()V public fun addConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)Z + public fun close ()V public fun getConnectionStatus ()Lio/sentry/IConnectionStatusProvider$ConnectionStatus; public fun getConnectionType ()Ljava/lang/String; public fun removeConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)V diff --git a/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java b/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java index 1d75098e564..bd897882d7a 100644 --- a/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java +++ b/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java @@ -1,11 +1,12 @@ package io.sentry; +import java.io.Closeable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public interface IConnectionStatusProvider { +public interface IConnectionStatusProvider extends Closeable { enum ConnectionStatus { UNKNOWN, diff --git a/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java b/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java index a1d66c9115b..765c2c0537d 100644 --- a/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java +++ b/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java @@ -1,5 +1,6 @@ package io.sentry; +import java.io.IOException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,4 +26,9 @@ public boolean addConnectionStatusObserver(@NotNull IConnectionStatusObserver ob public void removeConnectionStatusObserver(@NotNull IConnectionStatusObserver observer) { // no-op } + + @Override + public void close() throws IOException { + // no-op + } } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index fa1b7c2a81e..4b16d71b931 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -446,6 +446,7 @@ public void close(final boolean isRestarting) { getOptions().getTransactionProfiler().close(); getOptions().getContinuousProfiler().close(true); getOptions().getCompositePerformanceCollector().close(); + getOptions().getConnectionStatusProvider().close(); final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService(); if (isRestarting) { executorService.submit( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d0983db8258..469b2f4b160 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -592,6 +592,8 @@ class SentryOptionsTest { val options = SentryOptions() val customProvider = object : IConnectionStatusProvider { + override fun close() = Unit + override fun getConnectionStatus(): IConnectionStatusProvider.ConnectionStatus { return IConnectionStatusProvider.ConnectionStatus.UNKNOWN } From 270a4c33425df3e8f590a27a25cb727339c766be Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 31 Jul 2025 00:16:06 +0200 Subject: [PATCH 06/21] fix(breadcrumbs): Deduplicate battery breadcrumbs (#4561) --- CHANGELOG.md | 2 + .../SystemEventsBreadcrumbsIntegration.java | 67 +++++++++++---- .../SystemEventsBreadcrumbsIntegrationTest.kt | 81 ++++++++++++++++++- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7fa0aae2e..f8f0c0c1ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixes - Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) +- Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) + ## 8.18.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 27dcbbbd725..f4dd4fbc94f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -71,6 +71,8 @@ public final class SystemEventsBreadcrumbsIntegration implements Integration, Cl private volatile boolean isStopped = false; private volatile IntentFilter filter = null; private final @NotNull AutoClosableReentrantLock receiverLock = new AutoClosableReentrantLock(); + // Track previous battery state to avoid duplicate breadcrumbs when values haven't changed + private @Nullable BatteryState previousBatteryState; public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { this(context, getDefaultActionsInternal()); @@ -331,7 +333,7 @@ public void onStop(@NonNull LifecycleOwner owner) { } } - static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { + final class SystemEventsBroadcastReceiver extends BroadcastReceiver { private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; private final @NotNull IScopes scopes; @@ -350,19 +352,36 @@ public void onReceive(final Context context, final @NotNull Intent intent) { final @Nullable String action = intent.getAction(); final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); - // aligning with iOS which only captures battery status changes every minute at maximum - if (isBatteryChanged && batteryChangedDebouncer.checkForDebounce()) { - return; + @Nullable BatteryState batteryState = null; + if (isBatteryChanged) { + if (batteryChangedDebouncer.checkForDebounce()) { + // aligning with iOS which only captures battery status changes every minute at maximum + return; + } + + // For battery changes, check if the actual values have changed + final @Nullable Float batteryLevel = DeviceInfoUtil.getBatteryLevel(intent, options); + final @Nullable Integer currentBatteryLevel = + batteryLevel != null ? batteryLevel.intValue() : null; + final @Nullable Boolean currentChargingState = DeviceInfoUtil.isCharging(intent, options); + batteryState = new BatteryState(currentBatteryLevel, currentChargingState); + + // Only create breadcrumb if battery state has actually changed + if (batteryState.equals(previousBatteryState)) { + return; + } + + previousBatteryState = batteryState; } + final BatteryState state = batteryState; final long now = System.currentTimeMillis(); try { options .getExecutorService() .submit( () -> { - final Breadcrumb breadcrumb = - createBreadcrumb(now, intent, action, isBatteryChanged); + final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state); final Hint hint = new Hint(); hint.set(ANDROID_INTENT, intent); scopes.addBreadcrumb(breadcrumb, hint); @@ -411,7 +430,7 @@ String getStringAfterDotFast(final @Nullable String str) { final long timeMs, final @NotNull Intent intent, final @Nullable String action, - boolean isBatteryChanged) { + final @Nullable BatteryState batteryState) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); @@ -420,14 +439,12 @@ String getStringAfterDotFast(final @Nullable String str) { breadcrumb.setData("action", shortAction); } - if (isBatteryChanged) { - final Float batteryLevel = DeviceInfoUtil.getBatteryLevel(intent, options); - if (batteryLevel != null) { - breadcrumb.setData("level", batteryLevel); + if (batteryState != null) { + if (batteryState.level != null) { + breadcrumb.setData("level", batteryState.level); } - final Boolean isCharging = DeviceInfoUtil.isCharging(intent, options); - if (isCharging != null) { - breadcrumb.setData("charging", isCharging); + if (batteryState.charging != null) { + breadcrumb.setData("charging", batteryState.charging); } } else { final Bundle extras = intent.getExtras(); @@ -458,4 +475,26 @@ String getStringAfterDotFast(final @Nullable String str) { return breadcrumb; } } + + static final class BatteryState { + private final @Nullable Integer level; + private final @Nullable Boolean charging; + + BatteryState(final @Nullable Integer level, final @Nullable Boolean charging) { + this.level = level; + this.charging = charging; + } + + @Override + public boolean equals(final @Nullable Object other) { + if (!(other instanceof BatteryState)) return false; + BatteryState that = (BatteryState) other; + return Objects.equals(level, that.level) && Objects.equals(charging, that.charging); + } + + @Override + public int hashCode() { + return Objects.hash(level, charging); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 99a80f361c5..650c36868ba 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -157,7 +157,7 @@ class SystemEventsBreadcrumbsIntegrationTest { assertEquals("device.event", it.category) assertEquals("system", it.type) assertEquals(SentryLevel.INFO, it.level) - assertEquals(it.data["level"], 75f) + assertEquals(it.data["level"], 75) assertEquals(it.data["charging"], true) }, anyOrNull(), @@ -189,7 +189,7 @@ class SystemEventsBreadcrumbsIntegrationTest { verify(fixture.scopes) .addBreadcrumb( check { - assertEquals(it.data["level"], 80f) + assertEquals(it.data["level"], 80) assertEquals(it.data["charging"], false) }, anyOrNull(), @@ -197,6 +197,83 @@ class SystemEventsBreadcrumbsIntegrationTest { verifyNoMoreInteractions(fixture.scopes) } + @Test + fun `battery changes with identical values do not generate breadcrumbs`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + val intent1 = + Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + val intent2 = + Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + + // Receive first battery change + sut.receiver!!.onReceive(fixture.context, intent1) + + // Receive second battery change with identical values + sut.receiver!!.onReceive(fixture.context, intent2) + + // should only add the first crumb since values are identical + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals(it.data["level"], 80) + assertEquals(it.data["charging"], true) + }, + anyOrNull(), + ) + verifyNoMoreInteractions(fixture.scopes) + } + + @Test + fun `battery changes with minor level differences do not generate breadcrumbs`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + val intent1 = + Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80001) // 80.001% + putExtra(BatteryManager.EXTRA_SCALE, 100000) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + val intent2 = + Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80002) // 80.002% + putExtra(BatteryManager.EXTRA_SCALE, 100000) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + + // Receive first battery change + sut.receiver!!.onReceive(fixture.context, intent1) + + // Receive second battery change with very minor level difference (rounds to same 3 decimal + // places) + sut.receiver!!.onReceive(fixture.context, intent2) + + // should only add the first crumb since both round to 80.000% + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals(it.data["level"], 80) + assertEquals(it.data["charging"], true) + }, + anyOrNull(), + ) + verifyNoMoreInteractions(fixture.scopes) + } + @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() From 039ffbc154d687dc438195792434062ecad99e67 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 31 Jul 2025 08:55:54 +0200 Subject: [PATCH 07/21] fix(ci): remove obsolete NDK debug symbols (#4581) As they don't exist anymore and this is done within sentry-native directly: https://github.com/getsentry/sentry-native/pull/1327/files --- .github/workflows/release-build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 19a1981e238..84d41833f7a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -39,4 +39,3 @@ jobs: path: | ./*/build/distributions/*.zip ./sentry-opentelemetry/*/build/distributions/*.zip - ./sentry-android-ndk/build/intermediates/merged_native_libs/release/out/lib/* From 3fd31b66892c52288513d7fe8aa2822009b5b671 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 31 Jul 2025 15:39:52 +0200 Subject: [PATCH 08/21] fix(android): Remove unused method (#4585) * fix(android): Remove unused method * Update Changelog --- CHANGELOG.md | 1 + .../android/core/ManifestMetadataReader.java | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0c0c1ad8..d9bc29f4b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) - Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) +- Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585)) ## 8.18.0 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 37c48b2f087..3de708ce34c 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 @@ -532,23 +532,6 @@ private static boolean readBool( return value; } - @SuppressWarnings("deprecation") - private static @Nullable Boolean readBoolNullable( - final @NotNull Bundle metadata, - final @NotNull ILogger logger, - final @NotNull String key, - final @Nullable Boolean defaultValue) { - if (metadata.getSerializable(key) != null) { - final boolean nonNullDefault = defaultValue == null ? false : true; - final boolean bool = metadata.getBoolean(key, nonNullDefault); - logger.log(SentryLevel.DEBUG, key + " read: " + bool); - return bool; - } else { - logger.log(SentryLevel.DEBUG, key + " used default " + defaultValue); - return defaultValue; - } - } - private static @Nullable String readString( final @NotNull Bundle metadata, final @NotNull ILogger logger, From 10e42cedd167db6b034d4e10848f6b84feb086d2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 1 Aug 2025 11:12:45 +0200 Subject: [PATCH 09/21] Add rules file for documenting SDK offline behaviour (#4572) #skip-changelog ## :scroll: Description Add rules file for documenting SDK offline behaviour ## :bulb: Motivation and Context Should help speed up AI reasoning about the SDK offline/retry behaviour. ## :green_heart: How did you test it? ## :pencil: Checklist - [ ] I added tests to verify the changes. - [ ] No new PII added or SDK only sends newly added PII if `sendDefaultPII` is enabled. - [ ] I updated the docs if needed. - [ ] I updated the wizard if needed. - [ ] Review from the native team if needed. - [ ] No breaking change or entry added to the changelog. - [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs. ## :crystal_ball: Next steps --- .cursor/rules/offline.mdc | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .cursor/rules/offline.mdc diff --git a/.cursor/rules/offline.mdc b/.cursor/rules/offline.mdc new file mode 100644 index 00000000000..afb69233f38 --- /dev/null +++ b/.cursor/rules/offline.mdc @@ -0,0 +1,87 @@ +--- +alwaysApply: true +description: Java SDK Offline behaviour +--- +# Java SDK Offline behaviour + +By default offline caching is enabled for Android but disabled for JVM. +It can be enabled by setting SentryOptions.cacheDirPath. + +For Android, AndroidEnvelopeCache is used. For JVM, if cache path has been configured, EnvelopeCache will be used. + +Any error, event, transaction, profile, replay etc. is turned into an envelope and then sent into ITransport.send. +The default implementation is AsyncHttpTransport. + +If an envelope is dropped due to rate limit and has previously been cached (Cached hint) it will be discarded from the IEnvelopeCache. + +AsyncHttpTransport.send will enqueue an AsyncHttpTransport.EnvelopeSender task onto an executor. + +Any envelope that doesn't have the Cached hint will be stored in IEnvelopeCache by the EventSender task. Previously cached envelopes (Cached hint) will have a noop cache passed to AsyncHttpTransport.EnvelopeSender and thus not cache again. It is also possible cache is disabled in general. + +An envelope being sent directly from SDK API like Sentry.captureException will not have the Retryable hint. + +In case the SDK is offline, it'll mark the envelope to be retried if it has the Retryable hint. +If the envelope is not retryable and hasn't been sent to offline cache, it's recorded as lost in a client report. + +In case the envelope can't be sent due to an error or network connection problems it'll be marked for retry if it has the Retryable hint. +If it's not retryable and hasn't been cached, it's recorded as lost in a client report. + +In case the envelope is sent successfully, it'll be discarded from cache. + +The SDK has multiple mechanisms to deal with envelopes on disk. +- OutboxSender: Sends events coming from other SDKs like NDK that wrote them to disk. +- io.sentry.EnvelopeSender: This is the offline cache. + +Both of these are set up through an integration (SendCachedEnvelopeIntegration) which is configured to use SendFireAndForgetOutboxSender or SendFireAndForgetEnvelopeSender. + +io.sentry.EnvelopeSender is able to pick up files in the cache directory and send them. +It will trigger sending envelopes in cache dir on init and when the connection status changes (e.g. the SDK comes back online, meaning it has Internet connection again). + +## When Envelope Files Are Removed From Cache + +Envelope files are removed from the cache directory in the following scenarios: + +### 1. Successful Send to Sentry Server +When `AsyncHttpTransport` successfully sends an envelope to the Sentry server, it calls `envelopeCache.discard(envelope)` to remove the cached file. This happens in `AsyncHttpTransport.EnvelopeSender.flush()` when `result.isSuccess()` is true. + +### 2. Rate Limited Previously Cached Envelopes +If an envelope is dropped due to rate limiting **and** has previously been cached (indicated by the `Cached` hint), it gets discarded immediately via `envelopeCache.discard(envelope)` in `AsyncHttpTransport.send()`. +In this case the discarded envelope is recorded as lost in client reports. + +### 3. Offline Cache Processing (EnvelopeSender) +When the SDK processes cached envelope files from disk (via `EnvelopeSender`), files are deleted after processing **unless** they are marked for retry. In `EnvelopeSender.processFile()`, the file is deleted with `safeDelete(file)` if `!retryable.isRetry()`. + +### 4. Session File Management +Session-related files (session.json, previous_session.json) are removed during session lifecycle events like session start/end and abnormal exits. + +### 5. Cache rotation +If the number of files in the cache directory has reached the configured limit (SentryOptions.maxCacheItems), the oldest file will be deleted to make room. +This happens in `CacheStrategy.rotateCacheIfNeeded`. The deleted envelope will be recorded as lost in client reports. + +## Retry Mechanism + +**Important**: The SDK does NOT implement a traditional "max retry count" mechanism. Instead: + +### Infinite Retry Approach +- **Retryable envelopes**: Stay in cache indefinitely and are retried when conditions improve (network connectivity restored, rate limits expire, etc.) +- **Non-retryable envelopes**: If they fail to send, they're immediately recorded as lost (not cached for retry) + +### When Envelopes Are Permanently Lost (Not Due to Retry Limits) + +1. **Queue Overflow**: When the transport executor queue is full - recorded as `DiscardReason.QUEUE_OVERFLOW` + +2. **Network Errors (Non-Retryable)**: When an envelope isn't marked as retryable and fails due to network issues - recorded as `DiscardReason.NETWORK_ERROR` + +3. **Rate Limiting**: When envelope items are dropped due to active rate limits - recorded as `DiscardReason.RATELIMIT_BACKOFF` + +4. **Cache Overflow**: When the cache directory has reached maxCacheItems, old files are deleted - recorded as `DiscardReason.CACHE_OVERFLOW` + +### Cache Processing Triggers +Cached envelopes are processed when: +- Network connectivity is restored (via connection status observer) +- SDK initialization occurs +- Rate limits expire +- Manual flush operations + +### File Deletion Implementation +The actual file deletion is handled by `EnvelopeCache.discard()` which calls `envelopeFile.delete()` and logs errors if deletion fails. From e45908a3a467f1c051cfd2b90a3ed5a5edd58b85 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Aug 2025 15:29:15 +0200 Subject: [PATCH 10/21] perf(connectivity): Have only one NetworkCallback active at a time (#4562) --- CHANGELOG.md | 2 +- .../api/sentry-android-core.api | 2 +- .../core/AndroidOptionsInitializer.java | 3 +- .../core/NetworkBreadcrumbsIntegration.java | 109 ++++++------------ .../core/SendCachedEnvelopeIntegration.java | 4 +- .../util/AndroidConnectionStatusProvider.java | 69 ++++++++++- .../core/NetworkBreadcrumbsIntegrationTest.kt | 84 +------------- .../AndroidConnectionStatusProviderTest.kt | 53 ++++----- .../android/replay/capture/CaptureStrategy.kt | 25 +++- ...achedEnvelopeFireAndForgetIntegration.java | 4 +- 10 files changed, 160 insertions(+), 195 deletions(-) rename sentry-android-core/src/test/java/io/sentry/android/core/{ => internal/util}/AndroidConnectionStatusProviderTest.kt (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bc29f4b32..4d7a68f409c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) - Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) - Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585)) - +- Have single `NetworkCallback` registered at a time to reduce IPC calls ([#4562](https://github.com/getsentry/sentry-java/pull/4562)) ## 8.18.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0bf0d44427d..7e0cf42507b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -263,7 +263,7 @@ public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration } public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/ILogger;)V + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 04d927ff79e..33b003e9081 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -382,8 +382,7 @@ static void installDefaultIntegrations( } options.addIntegration(new AppComponentsBreadcrumbsIntegration(context)); options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); - options.addIntegration( - new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); + options.addIntegration(new NetworkBreadcrumbsIntegration(context, buildInfoProvider)); if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index 5cfb5df2235..c7f3182b97d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -12,7 +12,6 @@ import io.sentry.Breadcrumb; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; @@ -33,22 +32,17 @@ public final class NetworkBreadcrumbsIntegration implements Integration, Closeab private final @NotNull Context context; private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull ILogger logger; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - private volatile boolean isClosed; private @Nullable SentryOptions options; @TestOnly @Nullable volatile NetworkBreadcrumbsNetworkCallback networkCallback; public NetworkBreadcrumbsIntegration( - final @NotNull Context context, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ILogger logger) { + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { this.context = Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); - this.logger = Objects.requireNonNull(logger, "ILogger is required"); } @Override @@ -59,78 +53,54 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - logger.log( - SentryLevel.DEBUG, - "NetworkBreadcrumbsIntegration enabled: %s", - androidOptions.isEnableNetworkEventBreadcrumbs()); - this.options = options; + options + .getLogger() + .log( + SentryLevel.DEBUG, + "NetworkBreadcrumbsIntegration enabled: %s", + androidOptions.isEnableNetworkEventBreadcrumbs()); + if (androidOptions.isEnableNetworkEventBreadcrumbs()) { // The specific error is logged in the ConnectivityChecker method if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { - logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); + options.getLogger().log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return; } - try { - options - .getExecutorService() - .submit( - new Runnable() { - @Override - public void run() { - // in case integration is closed before the task is executed, simply return - if (isClosed) { - return; - } - - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - networkCallback = - new NetworkBreadcrumbsNetworkCallback( - scopes, buildInfoProvider, options.getDateProvider()); - - final boolean registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - if (registered) { - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("NetworkBreadcrumbs"); - } else { - logger.log( - SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); - // The specific error is logged by AndroidConnectionStatusProvider - } - } - } - }); - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + networkCallback = + new NetworkBreadcrumbsNetworkCallback( + scopes, buildInfoProvider, options.getDateProvider()); + + final boolean registered = + AndroidConnectionStatusProvider.addNetworkCallback( + context, options.getLogger(), buildInfoProvider, networkCallback); + if (registered) { + options.getLogger().log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion("NetworkBreadcrumbs"); + } else { + options + .getLogger() + .log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); + // The specific error is logged by AndroidConnectionStatusProvider + } } } } @Override public void close() throws IOException { - isClosed = true; + final @Nullable ConnectivityManager.NetworkCallback callbackRef; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + callbackRef = networkCallback; + networkCallback = null; + } - try { - Objects.requireNonNull(options, "Options is required") - .getExecutorService() - .submit( - () -> { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (networkCallback != null) { - AndroidConnectionStatusProvider.unregisterNetworkCallback( - context, logger, networkCallback); - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration removed."); - } - networkCallback = null; - } - }); - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); + if (callbackRef != null) { + AndroidConnectionStatusProvider.removeNetworkCallback(callbackRef); } } @@ -138,8 +108,6 @@ static final class NetworkBreadcrumbsNetworkCallback extends ConnectivityManager final @NotNull IScopes scopes; final @NotNull BuildInfoProvider buildInfoProvider; - @Nullable Network currentNetwork = null; - @Nullable NetworkCapabilities lastCapabilities = null; long lastCapabilityNanos = 0; final @NotNull SentryDateProvider dateProvider; @@ -156,21 +124,14 @@ static final class NetworkBreadcrumbsNetworkCallback extends ConnectivityManager @Override public void onAvailable(final @NonNull Network network) { - if (network.equals(currentNetwork)) { - return; - } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_AVAILABLE"); scopes.addBreadcrumb(breadcrumb); - currentNetwork = network; lastCapabilities = null; } @Override public void onCapabilitiesChanged( final @NonNull Network network, final @NonNull NetworkCapabilities networkCapabilities) { - if (!network.equals(currentNetwork)) { - return; - } final long nowNanos = dateProvider.now().nanoTimestamp(); final @Nullable NetworkBreadcrumbConnectionDetail connectionDetail = getNewConnectionDetails( @@ -195,12 +156,8 @@ public void onCapabilitiesChanged( @Override public void onLost(final @NonNull Network network) { - if (!network.equals(currentNetwork)) { - return; - } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_LOST"); scopes.addBreadcrumb(breadcrumb); - currentNetwork = null; lastCapabilities = null; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java index 41f4f838bf5..8eea2d71047 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java @@ -75,7 +75,9 @@ public void close() throws IOException { @Override public void onConnectionStatusChanged( final @NotNull IConnectionStatusProvider.ConnectionStatus status) { - if (scopes != null && options != null) { + if (scopes != null + && options != null + && status != IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) { sendCachedEnvelopes(scopes, options); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index 6308f704009..16ccc0a2ee4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -9,6 +9,7 @@ import android.net.NetworkCapabilities; import android.os.Build; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import io.sentry.IConnectionStatusProvider; import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; @@ -45,6 +46,10 @@ public final class AndroidConnectionStatusProvider implements IConnectionStatusP new AutoClosableReentrantLock(); private static volatile @Nullable ConnectivityManager connectivityManager; + private static final @NotNull AutoClosableReentrantLock childCallbacksLock = + new AutoClosableReentrantLock(); + private static final @NotNull List childCallbacks = new ArrayList<>(); + private static final int[] transports = { NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR, @@ -161,11 +166,24 @@ private void ensureNetworkCallbackRegistered() { @Override public void onAvailable(final @NotNull Network network) { currentNetwork = network; + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onAvailable(network); + } + } } + @RequiresApi(Build.VERSION_CODES.O) @Override public void onUnavailable() { clearCacheAndNotifyObservers(); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onUnavailable(); + } + } } @Override @@ -174,6 +192,12 @@ public void onLost(final @NotNull Network network) { return; } clearCacheAndNotifyObservers(); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onLost(network); + } + } } private void clearCacheAndNotifyObservers() { @@ -203,6 +227,12 @@ public void onCapabilitiesChanged( return; } updateCacheAndNotifyObservers(network, networkCapabilities); + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onCapabilitiesChanged(network, networkCapabilities); + } + } } private void updateCacheAndNotifyObservers( @@ -410,6 +440,9 @@ public void close() { currentNetwork = null; lastCacheUpdateTime = 0; } + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.clear(); + } try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { connectivityManager = null; @@ -618,8 +651,35 @@ public NetworkCapabilities getCachedNetworkCapabilities() { } } + public static boolean addNetworkCallback( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull NetworkCallback networkCallback) { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); + return false; + } + + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(SentryLevel.INFO, "No permission (ACCESS_NETWORK_STATE) to check network status."); + return false; + } + + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.add(networkCallback); + } + return true; + } + + public static void removeNetworkCallback(final @NotNull NetworkCallback networkCallback) { + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.remove(networkCallback); + } + } + @SuppressLint({"MissingPermission", "NewApi"}) - public static boolean registerNetworkCallback( + static boolean registerNetworkCallback( final @NotNull Context context, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, @@ -646,7 +706,7 @@ public static boolean registerNetworkCallback( } @SuppressLint("NewApi") - public static void unregisterNetworkCallback( + static void unregisterNetworkCallback( final @NotNull Context context, final @NotNull ILogger logger, final @NotNull NetworkCallback networkCallback) { @@ -681,7 +741,8 @@ public Thread getInitThread() { } @TestOnly - public static void setConnectivityManager(final @Nullable ConnectivityManager cm) { - connectivityManager = cm; + @NotNull + public static List getChildCallbacks() { + return childCallbacks; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 768fe87fbbd..4f6ba7fc5f0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -1,8 +1,6 @@ package io.sentry.android.core import android.content.Context -import android.net.ConnectivityManager -import android.net.ConnectivityManager.NetworkCallback import android.net.Network import android.net.NetworkCapabilities import android.os.Build @@ -17,7 +15,6 @@ import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider -import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -30,9 +27,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.KInOrder import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check -import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -46,7 +41,6 @@ class NetworkBreadcrumbsIntegrationTest { var options = SentryAndroidOptions() val scopes = mock() val mockBuildInfoProvider = mock() - val connectivityManager = mock() var nowMs: Long = 0 val network = mock() @@ -64,7 +58,7 @@ class NetworkBreadcrumbsIntegrationTest { SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) } } - return NetworkBreadcrumbsIntegration(context, buildInfo, options.logger) + return NetworkBreadcrumbsIntegration(context, buildInfo) } } @@ -73,13 +67,11 @@ class NetworkBreadcrumbsIntegrationTest { @BeforeTest fun `set up`() { whenever(fixture.mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(fixture.context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))) - .thenReturn(fixture.connectivityManager) } @AfterTest fun `tear down`() { - AndroidConnectionStatusProvider.setConnectivityManager(null) + AndroidConnectionStatusProvider.getChildCallbacks().clear() } @Test @@ -88,7 +80,7 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - verify(fixture.connectivityManager).registerDefaultNetworkCallback(any()) + assertFalse(AndroidConnectionStatusProvider.getChildCallbacks().isEmpty()) assertNotNull(sut.networkCallback) } @@ -98,7 +90,7 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) + assertTrue(AndroidConnectionStatusProvider.getChildCallbacks().isEmpty()) assertNull(sut.networkCallback) } @@ -110,7 +102,7 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) + assertTrue(AndroidConnectionStatusProvider.getChildCallbacks().isEmpty()) assertNull(sut.networkCallback) } @@ -121,21 +113,7 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.close() - verify(fixture.connectivityManager).unregisterNetworkCallback(any()) - assertNull(sut.networkCallback) - } - - @Test - fun `When NetworkBreadcrumbsIntegration is closed, it's ignored if not on Android N+`() { - val buildInfo = mock() - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) - val sut = fixture.getSut(buildInfo = buildInfo) - assertNull(sut.networkCallback) - - sut.register(fixture.scopes, fixture.options) - sut.close() - - verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + assertTrue(AndroidConnectionStatusProvider.getChildCallbacks().isEmpty()) assertNull(sut.networkCallback) } @@ -158,18 +136,6 @@ class NetworkBreadcrumbsIntegrationTest { ) } - @Test - fun `When connected to the same network without disconnecting from the previous one, only one breadcrumb is captured`() { - val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) - val callback = sut.networkCallback - assertNotNull(callback) - callback.onAvailable(fixture.network) - callback.onAvailable(fixture.network) - - verify(fixture.scopes, times(1)).addBreadcrumb(any()) - } - @Test fun `When disconnected from a network, a breadcrumb is captured`() { val sut = fixture.getSut() @@ -192,17 +158,6 @@ class NetworkBreadcrumbsIntegrationTest { ) } - @Test - fun `When disconnected from a network, a breadcrumb is captured only if previously connected to that network`() { - val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) - val callback = sut.networkCallback - assertNotNull(callback) - // callback.onAvailable(network) was not called, so no breadcrumb should be captured - callback.onLost(mock()) - verify(fixture.scopes, never()).addBreadcrumb(any()) - } - @Test fun `When a network connection detail changes, a breadcrumb is captured`() { val buildInfo = mock() @@ -241,17 +196,6 @@ class NetworkBreadcrumbsIntegrationTest { ) } - @Test - fun `When a network connection detail changes, a breadcrumb is captured only if previously connected to that network`() { - val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) - val callback = sut.networkCallback - assertNotNull(callback) - // callback.onAvailable(network) was not called, so no breadcrumb should be captured - onCapabilitiesChanged(callback, mock()) - verify(fixture.scopes, never()).addBreadcrumb(any(), anyOrNull()) - } - @Test fun `When a network connection detail changes, a new breadcrumb is captured if vpn flag changes`() { val sut = fixture.getSut() @@ -503,22 +447,6 @@ class NetworkBreadcrumbsIntegrationTest { } } - @Test - fun `If integration is opened and closed immediately it still properly unregisters`() { - val executor = DeferredExecutorService() - val sut = fixture.getSut(executor = executor) - - sut.register(fixture.scopes, fixture.options) - sut.close() - - executor.runAll() - - assertNull(sut.networkCallback) - verify(fixture.connectivityManager, never()) - .registerDefaultNetworkCallback(any()) - verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) - } - private fun KInOrder.verifyBreadcrumbInOrder( check: (detail: NetworkBreadcrumbConnectionDetail) -> Unit ) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt similarity index 96% rename from sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index a362885d635..6769ac15301 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.core +package io.sentry.android.core.internal.util import android.Manifest import android.content.Context @@ -18,7 +18,7 @@ import android.os.Build import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.SentryOptions -import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider +import io.sentry.android.core.BuildInfoProvider import io.sentry.test.ImmediateExecutorService import io.sentry.transport.ICurrentDateProvider import kotlin.test.AfterTest @@ -37,6 +37,7 @@ import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class AndroidConnectionStatusProviderTest { @@ -68,7 +69,7 @@ class AndroidConnectionStatusProviderTest { whenever(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) buildInfo = mock() - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) network = mock() whenever(connectivityManager.activeNetwork).thenReturn(network) @@ -173,12 +174,6 @@ class AndroidConnectionStatusProviderTest { providerWithNullConnectivity.close() } - @Test - fun `When ConnectivityManager is not available, return null for getConnectionType`() { - whenever(contextMock.getSystemService(any())).thenReturn(null) - assertNull(connectionStatusProvider.connectionType) - } - @Test fun `When there's no permission, return null for getConnectionType`() { whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) @@ -227,23 +222,6 @@ class AndroidConnectionStatusProviderTest { assertEquals("cellular", connectionStatusProvider.connectionType) } - @Test - fun `registerNetworkCallback calls connectivityManager registerDefaultNetworkCallback`() { - val buildInfo = mock() - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - val registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - contextMock, - logger, - buildInfo, - mock(), - ) - - assertTrue(registered) - verify(connectivityManager).registerDefaultNetworkCallback(any()) - } - @Test fun `unregisterNetworkCallback calls connectivityManager unregisterDefaultNetworkCallback`() { whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) @@ -631,4 +609,27 @@ class AndroidConnectionStatusProviderTest { // changed significantly) verify(observer, times(2)).onConnectionStatusChanged(any()) } + + @Test + fun `childCallbacks receive network events dispatched by provider`() { + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val mainCallback = connectionStatusProvider.networkCallback + assertNotNull(mainCallback) + + // Register a mock child callback + val childCallback = mock() + AndroidConnectionStatusProvider.getChildCallbacks().add(childCallback) + + // Simulate event on available + mainCallback.onAvailable(network) + + // Assert child callback received the event + verify(childCallback).onAvailable(network) + + // Remove it and ensure it no longer receives events + AndroidConnectionStatusProvider.getChildCallbacks().remove(childCallback) + mainCallback.onAvailable(network) + verifyNoMoreInteractions(childCallback) + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 90d830eee47..2ae12c03c3a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -54,7 +54,15 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy companion object { - private const val BREADCRUMB_START_OFFSET = 100L + private fun Breadcrumb?.isNetworkAvailable(): Boolean = + this != null && + category == "network.event" && + data.getOrElse("action", { null }) == "NETWORK_AVAILABLE" + + private fun Breadcrumb.isNetworkConnectivity(): Boolean = + category == "network.event" && data.containsKey("network_type") + + private const val NETWORK_BREADCRUMB_START_OFFSET = 5000L // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device // time is wrong and the segment is too long. @@ -168,12 +176,18 @@ internal interface CaptureStrategy { } val urls = LinkedList() + var previousCrumb: Breadcrumb? = null breadcrumbs.forEach { breadcrumb -> - // we add some fixed breadcrumb offset to make sure we don't miss any - // breadcrumbs that might be relevant for the current segment, but just happened - // earlier than the current segment (e.g. network connectivity changed) + // we special-case network-reconnected breadcrumb, because there's usually some delay after + // we receive onConnected callback and we resume ongoing replay recording. We still want + // this breadcrumb to be sent with the current segment, hence we give it more room to make + // it into the replay + val isAfterNetworkReconnected = + previousCrumb?.isNetworkAvailable() == true && + breadcrumb.isNetworkConnectivity() && + breadcrumb.timestamp.time + NETWORK_BREADCRUMB_START_OFFSET >= segmentTimestamp.time if ( - (breadcrumb.timestamp.time + BREADCRUMB_START_OFFSET) >= segmentTimestamp.time && + (breadcrumb.timestamp.time >= segmentTimestamp.time || isAfterNetworkReconnected) && breadcrumb.timestamp.time < endTimestamp.time ) { val rrwebEvent = options.replayController.breadcrumbConverter.convert(breadcrumb) @@ -190,6 +204,7 @@ internal interface CaptureStrategy { } } } + previousCrumb = breadcrumb } if (screenAtStart != null && urls.firstOrNull() != screenAtStart) { diff --git a/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java b/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java index 37ba75783a4..b08e140392c 100644 --- a/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java +++ b/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java @@ -97,7 +97,9 @@ public void close() throws IOException { @Override public void onConnectionStatusChanged( final @NotNull IConnectionStatusProvider.ConnectionStatus status) { - if (scopes != null && options != null) { + if (scopes != null + && options != null + && status != IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) { sendCachedEnvelopes(scopes, options); } } From 63b9d7b626f12147617714ce404fd153bfd47e21 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Aug 2025 17:17:45 +0200 Subject: [PATCH 11/21] fix(scripts): update-gradle script set-version (#4591) --- scripts/update-gradle.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/update-gradle.sh b/scripts/update-gradle.sh index 33de2b5f97a..c2bfe979224 100755 --- a/scripts/update-gradle.sh +++ b/scripts/update-gradle.sh @@ -32,10 +32,6 @@ set-version) version="${version:1}" fi - # Remove trailing ".0" - gradlew expects '7.1' instead of '7.1.0' - if [[ "$version" == *".0" ]]; then - version="${version:0:${#version}-2}" - fi echo "Setting gradle version to '$version'" # This sets version to gradle-wrapper.properties. From 0f2e1a2f1c4a76607a91ee5fb1daa48eed3b46f2 Mon Sep 17 00:00:00 2001 From: Ghasem Shirdel Date: Tue, 5 Aug 2025 11:32:02 +0330 Subject: [PATCH 12/21] fix: sentry-android-ndk proguard rule keeps all native class (#4427) * fix: sentry-androi-ndk proguard rule keeps all native class * docs: update CHANGELOG * fix: update CHANGELOG * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: Markus Hintersteiner Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 7 +++++++ sentry-android-ndk/proguard-rules.pro | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7a68f409c..d38a251b873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) - Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585)) - Have single `NetworkCallback` registered at a time to reduce IPC calls ([#4562](https://github.com/getsentry/sentry-java/pull/4562)) +- Limit ProGuard keep rules for native methods within `sentry-android-ndk` to the `io.sentry.**` namespace. ([#4427](https://github.com/getsentry/sentry-java/pull/4427)) + - If you relied on the Sentry SDK to keep native method names for JNI compatibility within your namespace, please review your ProGuard rules and ensure the configuration still works. Especially when you're not consuming any of the default Android proguard rules (`proguard-android.txt` or `proguard-android-optimize.txt`) the following config should be present: + ``` + -keepclasseswithmembernames class * { + native ; + } + ``` ## 8.18.0 diff --git a/sentry-android-ndk/proguard-rules.pro b/sentry-android-ndk/proguard-rules.pro index 09e6fb6c7c0..db825e27b4d 100644 --- a/sentry-android-ndk/proguard-rules.pro +++ b/sentry-android-ndk/proguard-rules.pro @@ -7,7 +7,7 @@ -keep class io.sentry.protocol.DebugImage { *; } # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native --keepclasseswithmembernames,includedescriptorclasses class * { +-keepclasseswithmembernames,includedescriptorclasses class io.sentry.** { native ; } From b1a54cac9346a9ee947e35cb00e15df517c90522 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 5 Aug 2025 11:34:21 +0200 Subject: [PATCH 13/21] refactor(lifecycle): Use single lifecycle observer (#4567) * perf(connectivity): Cache network capabilities and status to reduce IPC calls * changelog * Changelog * revert * fix(breadcrumbs): Deduplicate battery breadcrumbs * ref * Changelog * Fix test * perf(connectivity): Have only one NetworkCallback active at a time * Changelog * perf(integrations): Use single lifecycle observer * Add tests * Changelog * Fix tests * Improve callback handling and test visibility (#4593) * Null-check lifecycleObserver --------- Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 12 +- .../core/AndroidOptionsInitializer.java | 1 + .../android/core/AppLifecycleIntegration.java | 99 ++--- .../java/io/sentry/android/core/AppState.java | 178 ++++++++- .../sentry/android/core/LifecycleWatcher.java | 14 +- .../SystemEventsBreadcrumbsIntegration.java | 156 ++------ .../core/AppLifecycleIntegrationTest.kt | 54 +-- .../io/sentry/android/core/AppStateTest.kt | 344 ++++++++++++++++++ .../android/core/LifecycleWatcherTest.kt | 64 ++-- .../core/SessionTrackingIntegrationTest.kt | 11 +- .../SystemEventsBreadcrumbsIntegrationTest.kt | 113 +++--- 12 files changed, 690 insertions(+), 357 deletions(-) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d38a251b873..cb3a1154a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Improvements - Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) +- Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567)) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7e0cf42507b..044a1fded2b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -166,11 +166,17 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/AppState { +public final class io/sentry/android/core/AppState : java/io/Closeable { + public fun close ()V public static fun getInstance ()Lio/sentry/android/core/AppState; public fun isInBackground ()Ljava/lang/Boolean; } +public abstract interface class io/sentry/android/core/AppState$AppStateListener { + public abstract fun onBackground ()V + public abstract fun onForeground ()V +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -422,11 +428,13 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo public fun onSpanStarted (Lio/sentry/ISpan;)V } -public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { +public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V public static fun getDefaultActions ()Ljava/util/List; + public fun onBackground ()V + public fun onForeground ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 33b003e9081..21dde74d3ee 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -128,6 +128,7 @@ static void loadDefaultAndMetadataOptions( options.setCacheDirPath(getCacheDir(context).getAbsolutePath()); readDefaultOptionValues(options, context, buildInfoProvider); + AppState.getInstance().registerLifecycleObserver(options); } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 92bf2203481..9fd90b23099 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -2,12 +2,12 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; -import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -17,20 +17,11 @@ public final class AppLifecycleIntegration implements Integration, Closeable { + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly @Nullable volatile LifecycleWatcher watcher; private @Nullable SentryAndroidOptions options; - private final @NotNull MainLooperHandler handler; - - public AppLifecycleIntegration() { - this(new MainLooperHandler()); - } - - AppLifecycleIntegration(final @NotNull MainLooperHandler handler) { - this.handler = handler; - } - @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { Objects.requireNonNull(scopes, "Scopes are required"); @@ -55,85 +46,47 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (this.options.isEnableAutoSessionTracking() || this.options.isEnableAppLifecycleBreadcrumbs()) { - try { - Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); - Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidThreadChecker.getInstance().isMainThread()) { - addObserver(scopes); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> addObserver(scopes)); + try (final ISentryLifecycleToken ignored = lock.acquire()) { + if (watcher != null) { + return; } - } catch (ClassNotFoundException e) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed"); - } catch (IllegalStateException e) { - options - .getLogger() - .log(SentryLevel.ERROR, "AppLifecycleIntegration could not be installed", e); - } - } - } - private void addObserver(final @NotNull IScopes scopes) { - // this should never happen, check added to avoid warnings from NullAway - if (this.options == null) { - return; - } + watcher = + new LifecycleWatcher( + scopes, + this.options.getSessionTrackingIntervalMillis(), + this.options.isEnableAutoSessionTracking(), + this.options.isEnableAppLifecycleBreadcrumbs()); - watcher = - new LifecycleWatcher( - scopes, - this.options.getSessionTrackingIntervalMillis(), - this.options.isEnableAutoSessionTracking(), - this.options.isEnableAppLifecycleBreadcrumbs()); + AppState.getInstance().addAppStateListener(watcher); + } - try { - ProcessLifecycleOwner.get().getLifecycle().addObserver(watcher); options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); addIntegrationToSdkVersion("AppLifecycle"); - } catch (Throwable e) { - // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in - // connection with conflicting dependencies of the androidx.lifecycle. - // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 - watcher = null; - options - .getLogger() - .log( - SentryLevel.ERROR, - "AppLifecycleIntegration failed to get Lifecycle and could not be installed.", - e); } } private void removeObserver() { - final @Nullable LifecycleWatcher watcherRef = watcher; + final @Nullable LifecycleWatcher watcherRef; + try (final ISentryLifecycleToken ignored = lock.acquire()) { + watcherRef = watcher; + watcher = null; + } + if (watcherRef != null) { - ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); + AppState.getInstance().removeAppStateListener(watcherRef); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed."); } } - watcher = null; } @Override public void close() throws IOException { - if (watcher == null) { - return; - } - if (AndroidThreadChecker.getInstance().isMainThread()) { - removeObserver(); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - handler.post(() -> removeObserver()); - } + removeObserver(); + // TODO: probably should move it to Scopes.close(), but that'd require a new interface and + // different implementations for Java and Android. This is probably fine like this too, because + // integrations are closed in the same place + AppState.getInstance().unregisterLifecycleObserver(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java index d9633aed540..8fc1c8ab0b8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java @@ -1,7 +1,20 @@ package io.sentry.android.core; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; +import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpLogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.util.AutoClosableReentrantLock; +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -9,9 +22,11 @@ /** AppState holds the state of the App, e.g. whether the app is in background/foreground, etc. */ @ApiStatus.Internal -public final class AppState { +public final class AppState implements Closeable { private static @NotNull AppState instance = new AppState(); private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile LifecycleObserver lifecycleObserver; + private MainLooperHandler handler = new MainLooperHandler(); private AppState() {} @@ -19,7 +34,17 @@ private AppState() {} return instance; } - private @Nullable Boolean inBackground = null; + private volatile @Nullable Boolean inBackground = null; + + @TestOnly + LifecycleObserver getLifecycleObserver() { + return lifecycleObserver; + } + + @TestOnly + void setHandler(final @NotNull MainLooperHandler handler) { + this.handler = handler; + } @TestOnly void resetInstance() { @@ -31,8 +56,155 @@ void resetInstance() { } void setInBackground(final boolean inBackground) { + this.inBackground = inBackground; + } + + void addAppStateListener(final @NotNull AppStateListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(NoOpLogger.getInstance()); + + if (lifecycleObserver != null) { + lifecycleObserver.listeners.add(listener); + } + } + } + + void removeAppStateListener(final @NotNull AppStateListener listener) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - this.inBackground = inBackground; + if (lifecycleObserver != null) { + lifecycleObserver.listeners.remove(listener); + } } } + + void registerLifecycleObserver(final @Nullable SentryAndroidOptions options) { + if (lifecycleObserver != null) { + return; + } + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(options != null ? options.getLogger() : NoOpLogger.getInstance()); + } + } + + private void ensureLifecycleObserver(final @NotNull ILogger logger) { + if (lifecycleObserver != null) { + return; + } + try { + Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); + // create it right away, so it's available in addAppStateListener in case it's posted to main + // thread + lifecycleObserver = new LifecycleObserver(); + + if (AndroidThreadChecker.getInstance().isMainThread()) { + addObserverInternal(logger); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + handler.post(() -> addObserverInternal(logger)); + } + } catch (ClassNotFoundException e) { + logger.log( + SentryLevel.WARNING, + "androidx.lifecycle is not available, some features might not be properly working," + + "e.g. Session Tracking, Network and System Events breadcrumbs, etc."); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "AppState could not register lifecycle observer", e); + } + } + + private void addObserverInternal(final @NotNull ILogger logger) { + final @Nullable LifecycleObserver observerRef = lifecycleObserver; + try { + // might already be unregistered/removed so we have to check for nullability + if (observerRef != null) { + ProcessLifecycleOwner.get().getLifecycle().addObserver(observerRef); + } + } catch (Throwable e) { + // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in + // connection with conflicting dependencies of the androidx.lifecycle. + // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 + lifecycleObserver = null; + logger.log( + SentryLevel.ERROR, + "AppState failed to get Lifecycle and could not install lifecycle observer.", + e); + } + } + + void unregisterLifecycleObserver() { + if (lifecycleObserver == null) { + return; + } + + final @Nullable LifecycleObserver ref; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ref = lifecycleObserver; + lifecycleObserver.listeners.clear(); + lifecycleObserver = null; + } + + if (AndroidThreadChecker.getInstance().isMainThread()) { + removeObserverInternal(ref); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + handler.post(() -> removeObserverInternal(ref)); + } + } + + private void removeObserverInternal(final @Nullable LifecycleObserver ref) { + if (ref != null) { + ProcessLifecycleOwner.get().getLifecycle().removeObserver(ref); + } + } + + @Override + public void close() throws IOException { + unregisterLifecycleObserver(); + } + + final class LifecycleObserver implements DefaultLifecycleObserver { + final List listeners = + new CopyOnWriteArrayList() { + @Override + public boolean add(AppStateListener appStateListener) { + final boolean addResult = super.add(appStateListener); + // notify the listeners immediately to let them "catch up" with the current state + // (mimics the behavior of androidx.lifecycle) + if (Boolean.FALSE.equals(inBackground)) { + appStateListener.onForeground(); + } else if (Boolean.TRUE.equals(inBackground)) { + appStateListener.onBackground(); + } + return addResult; + } + }; + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + setInBackground(false); + for (AppStateListener listener : listeners) { + listener.onForeground(); + } + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + setInBackground(true); + for (AppStateListener listener : listeners) { + listener.onBackground(); + } + } + } + + // If necessary, we can adjust this and add other callbacks in the future + public interface AppStateListener { + void onForeground(); + + void onBackground(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 89d78193207..a5e4d398e74 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -1,7 +1,5 @@ package io.sentry.android.core; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -17,7 +15,7 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -final class LifecycleWatcher implements DefaultLifecycleObserver { +final class LifecycleWatcher implements AppState.AppStateListener { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); @@ -58,15 +56,10 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.currentDateProvider = currentDateProvider; } - // App goes to foreground @Override - public void onStart(final @NotNull LifecycleOwner owner) { + public void onForeground() { startSession(); addAppBreadcrumb("foreground"); - - // Consider using owner.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED); - // in the future. - AppState.getInstance().setInBackground(false); } private void startSession() { @@ -99,14 +92,13 @@ private void startSession() { // App went to background and triggered this callback after 700ms // as no new screen was shown @Override - public void onStop(final @NotNull LifecycleOwner owner) { + public void onBackground() { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); scopes.getOptions().getReplayController().pause(); scheduleEndSession(); - AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index f4dd4fbc94f..f8950ee7626 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -25,10 +25,6 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IScopes; @@ -37,7 +33,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; -import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -52,16 +47,13 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -public final class SystemEventsBreadcrumbsIntegration implements Integration, Closeable { +public final class SystemEventsBreadcrumbsIntegration + implements Integration, Closeable, AppState.AppStateListener { private final @NotNull Context context; @TestOnly @Nullable volatile SystemEventsBroadcastReceiver receiver; - @TestOnly @Nullable volatile ReceiverLifecycleHandler lifecycleHandler; - - private final @NotNull MainLooperHandler handler; - private @Nullable SentryAndroidOptions options; private @Nullable IScopes scopes; @@ -78,18 +70,10 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { this(context, getDefaultActionsInternal()); } - private SystemEventsBreadcrumbsIntegration( - final @NotNull Context context, final @NotNull String[] actions) { - this(context, actions, new MainLooperHandler()); - } - SystemEventsBreadcrumbsIntegration( - final @NotNull Context context, - final @NotNull String[] actions, - final @NotNull MainLooperHandler handler) { + final @NotNull Context context, final @NotNull String[] actions) { this.context = ContextUtils.getApplicationContext(context); this.actions = actions; - this.handler = handler; } public SystemEventsBreadcrumbsIntegration( @@ -97,7 +81,6 @@ public SystemEventsBreadcrumbsIntegration( this.context = ContextUtils.getApplicationContext(context); this.actions = new String[actions.size()]; actions.toArray(this.actions); - this.handler = new MainLooperHandler(); } @Override @@ -117,7 +100,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions this.options.isEnableSystemEventBreadcrumbs()); if (this.options.isEnableSystemEventBreadcrumbs()) { - addLifecycleObserver(this.options); + AppState.getInstance().addAppStateListener(this); registerReceiver(this.scopes, this.options, /* reportAsNewIntegration= */ true); } } @@ -131,10 +114,8 @@ private void registerReceiver( return; } - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - if (isClosed || isStopped || receiver != null) { - return; - } + if (isClosed || isStopped || receiver != null) { + return; } try { @@ -185,88 +166,25 @@ private void registerReceiver( } private void unregisterReceiver() { - final @Nullable SystemEventsBroadcastReceiver receiverRef; - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - isStopped = true; - receiverRef = receiver; - receiver = null; - } - - if (receiverRef != null) { - context.unregisterReceiver(receiverRef); - } - } - - // TODO: this duplicates a lot of AppLifecycleIntegration. We should register once on init - // and multiplex to different listeners rather. - private void addLifecycleObserver(final @NotNull SentryAndroidOptions options) { - try { - Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); - Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidThreadChecker.getInstance().isMainThread()) { - addObserverInternal(options); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> addObserverInternal(options)); - } - } catch (ClassNotFoundException e) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "androidx.lifecycle is not available, SystemEventsBreadcrumbsIntegration won't be able" - + " to register/unregister an internal BroadcastReceiver. This may result in an" - + " increased ANR rate on Android 14 and above."); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.ERROR, - "SystemEventsBreadcrumbsIntegration could not register lifecycle observer", - e); - } - } - - private void addObserverInternal(final @NotNull SentryAndroidOptions options) { - lifecycleHandler = new ReceiverLifecycleHandler(); - - try { - ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleHandler); - } catch (Throwable e) { - // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in - // connection with conflicting dependencies of the androidx.lifecycle. - // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 - lifecycleHandler = null; - options - .getLogger() - .log( - SentryLevel.ERROR, - "SystemEventsBreadcrumbsIntegration failed to get Lifecycle and could not install lifecycle observer.", - e); + if (options == null) { + return; } - } - private void removeLifecycleObserver() { - if (lifecycleHandler != null) { - if (AndroidThreadChecker.getInstance().isMainThread()) { - removeObserverInternal(); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - handler.post(() -> removeObserverInternal()); - } - } - } + options + .getExecutorService() + .submit( + () -> { + final @Nullable SystemEventsBroadcastReceiver receiverRef; + try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { + isStopped = true; + receiverRef = receiver; + receiver = null; + } - private void removeObserverInternal() { - final @Nullable ReceiverLifecycleHandler watcherRef = lifecycleHandler; - if (watcherRef != null) { - ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); - } - lifecycleHandler = null; + if (receiverRef != null) { + context.unregisterReceiver(receiverRef); + } + }); } @Override @@ -276,11 +194,11 @@ public void close() throws IOException { filter = null; } - removeLifecycleObserver(); + AppState.getInstance().removeAppStateListener(this); unregisterReceiver(); if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); + options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration removed."); } } @@ -313,24 +231,20 @@ public void close() throws IOException { return actions; } - final class ReceiverLifecycleHandler implements DefaultLifecycleObserver { - @Override - public void onStart(@NonNull LifecycleOwner owner) { - if (scopes == null || options == null) { - return; - } + @Override + public void onForeground() { + if (scopes == null || options == null) { + return; + } - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - isStopped = false; - } + isStopped = false; - registerReceiver(scopes, options, /* reportAsNewIntegration= */ false); - } + registerReceiver(scopes, options, /* reportAsNewIntegration= */ false); + } - @Override - public void onStop(@NonNull LifecycleOwner owner) { - unregisterReceiver(); - } + @Override + public void onBackground() { + unregisterReceiver(); } final class SystemEventsBroadcastReceiver extends BroadcastReceiver { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt index 6b2cafabe7f..896673085c2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt @@ -8,21 +8,17 @@ import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class AppLifecycleIntegrationTest { private class Fixture { val scopes = mock() - lateinit var handler: MainLooperHandler val options = SentryAndroidOptions() - fun getSut(mockHandler: Boolean = true): AppLifecycleIntegration { - handler = if (mockHandler) mock() else MainLooperHandler() - return AppLifecycleIntegration(handler) + fun getSut(): AppLifecycleIntegration { + return AppLifecycleIntegration() } } @@ -64,23 +60,7 @@ class AppLifecycleIntegrationTest { } @Test - fun `When AppLifecycleIntegration is registered from a background thread, post on the main thread`() { - val sut = fixture.getSut() - val latch = CountDownLatch(1) - - Thread { - sut.register(fixture.scopes, fixture.options) - latch.countDown() - } - .start() - - latch.await() - - verify(fixture.handler).post(any()) - } - - @Test - fun `When AppLifecycleIntegration is closed from a background thread, post on the main thread`() { + fun `When AppLifecycleIntegration is closed from a background thread, watcher is set to null`() { val sut = fixture.getSut() val latch = CountDownLatch(1) @@ -96,29 +76,25 @@ class AppLifecycleIntegrationTest { latch.await() - verify(fixture.handler).post(any()) + // ensure all messages on main looper got processed + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.watcher) } @Test - fun `When AppLifecycleIntegration is closed from a background thread, watcher is set to null`() { - val sut = fixture.getSut(mockHandler = false) - val latch = CountDownLatch(1) + fun `When AppLifecycleIntegration is closed, AppState unregisterLifecycleObserver is called`() { + val sut = fixture.getSut() + val appState = AppState.getInstance() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.watcher) + // Verify that lifecycleObserver is not null after registration + assertNotNull(appState.lifecycleObserver) - Thread { - sut.close() - latch.countDown() - } - .start() - - latch.await() - - // ensure all messages on main looper got processed - shadowOf(Looper.getMainLooper()).idle() + sut.close() - assertNull(sut.watcher) + // Verify that lifecycleObserver is null after unregistering + assertNull(appState.lifecycleObserver) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt new file mode 100644 index 00000000000..4fe39b20f2e --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt @@ -0,0 +1,344 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.AppState.AppStateListener +import io.sentry.android.core.internal.util.AndroidThreadChecker +import java.util.concurrent.CountDownLatch +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +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 + +@RunWith(AndroidJUnit4::class) +class AppStateTest { + + private class Fixture { + val mockThreadChecker: AndroidThreadChecker = mock() + val mockHandler: MainLooperHandler = mock() + val options = SentryAndroidOptions() + val listener: AppStateListener = mock() + lateinit var androidThreadCheckerMock: MockedStatic + + fun getSut(isMainThread: Boolean = true): AppState { + val appState = AppState.getInstance() + whenever(mockThreadChecker.isMainThread).thenReturn(isMainThread) + appState.setHandler(mockHandler) + return appState + } + + fun createListener(): AppStateListener = mock() + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + + // Mock AndroidThreadChecker + fixture.androidThreadCheckerMock = mockStatic(AndroidThreadChecker::class.java) + fixture.androidThreadCheckerMock + .`when` { AndroidThreadChecker.getInstance() } + .thenReturn(fixture.mockThreadChecker) + } + + @AfterTest + fun `tear down`() { + fixture.androidThreadCheckerMock.close() + } + + @Test + fun `getInstance returns singleton instance`() { + val instance1 = fixture.getSut() + val instance2 = fixture.getSut() + + assertSame(instance1, instance2) + } + + @Test + fun `resetInstance creates new instance`() { + val sut = fixture.getSut() + sut.setInBackground(true) + + sut.resetInstance() + + val newInstance = fixture.getSut() + assertNull(newInstance.isInBackground()) + } + + @Test + fun `isInBackground returns null initially`() { + val sut = fixture.getSut() + + assertNull(sut.isInBackground()) + } + + @Test + fun `setInBackground updates state`() { + val sut = fixture.getSut() + + sut.setInBackground(true) + assertTrue(sut.isInBackground()!!) + + sut.setInBackground(false) + assertFalse(sut.isInBackground()!!) + } + + @Test + fun `addAppStateListener creates lifecycle observer if needed`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + + assertNotNull(sut.lifecycleObserver) + } + + @Test + fun `addAppStateListener from background thread posts to main thread`() { + val sut = fixture.getSut(isMainThread = false) + + sut.addAppStateListener(fixture.listener) + + verify(fixture.mockHandler).post(any()) + } + + @Test + fun `addAppStateListener notifies listener with onForeground when in foreground state`() { + val sut = fixture.getSut() + + sut.setInBackground(false) + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener).onForeground() + verify(fixture.listener, never()).onBackground() + } + + @Test + fun `addAppStateListener notifies listener with onBackground when in background state`() { + val sut = fixture.getSut() + + sut.setInBackground(true) + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener).onBackground() + verify(fixture.listener, never()).onForeground() + } + + @Test + fun `addAppStateListener does not notify listener when state is unknown`() { + val sut = fixture.getSut() + + // State is null (unknown) by default + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener, never()).onForeground() + verify(fixture.listener, never()).onBackground() + } + + @Test + fun `removeAppStateListener removes listener`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + val observer = sut.lifecycleObserver + // Check that listener was added + assertNotNull(observer) + + sut.removeAppStateListener(fixture.listener) + // Listener should be removed but observer still exists + assertNotNull(sut.lifecycleObserver) + } + + @Test + fun `removeAppStateListener handles null lifecycle observer`() { + val sut = fixture.getSut() + + // Should not throw when lifecycleObserver is null + sut.removeAppStateListener(fixture.listener) + } + + @Test + fun `registerLifecycleObserver does nothing if already registered`() { + val sut = fixture.getSut() + + sut.registerLifecycleObserver(fixture.options) + val firstObserver = sut.lifecycleObserver + + sut.registerLifecycleObserver(fixture.options) + val secondObserver = sut.lifecycleObserver + + assertSame(firstObserver, secondObserver) + } + + @Test + fun `unregisterLifecycleObserver clears listeners and nulls observer`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + assertNotNull(sut.lifecycleObserver) + + sut.unregisterLifecycleObserver() + + assertNull(sut.lifecycleObserver) + } + + @Test + fun `unregisterLifecycleObserver handles null observer`() { + val sut = fixture.getSut() + + // Should not throw when lifecycleObserver is already null + sut.unregisterLifecycleObserver() + } + + @Test + fun `unregisterLifecycleObserver from background thread posts to main thread`() { + val sut = fixture.getSut(isMainThread = false) + + sut.registerLifecycleObserver(fixture.options) + + sut.unregisterLifecycleObserver() + + // 2 times - register and unregister + verify(fixture.mockHandler, times(2)).post(any()) + } + + @Test + fun `close calls unregisterLifecycleObserver`() { + val sut = fixture.getSut() + sut.addAppStateListener(fixture.listener) + + sut.close() + + assertNull(sut.lifecycleObserver) + } + + @Test + fun `LifecycleObserver onStart notifies all listeners and sets foreground`() { + val listener1 = fixture.createListener() + val listener2 = fixture.createListener() + val sut = fixture.getSut() + + // Add listeners to create observer + sut.addAppStateListener(listener1) + sut.addAppStateListener(listener2) + + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + + verify(listener1).onForeground() + verify(listener2).onForeground() + assertFalse(sut.isInBackground()!!) + } + + @Test + fun `LifecycleObserver onStop notifies all listeners and sets background`() { + val listener1 = fixture.createListener() + val listener2 = fixture.createListener() + val sut = fixture.getSut() + + // Add listeners to create observer + sut.addAppStateListener(listener1) + sut.addAppStateListener(listener2) + + val observer = sut.lifecycleObserver!! + observer.onStop(mock()) + + verify(listener1).onBackground() + verify(listener2).onBackground() + assertTrue(sut.isInBackground()!!) + } + + @Test + fun `a listener can be unregistered within a callback`() { + val sut = fixture.getSut() + + var onForegroundCalled = false + val listener = + object : AppStateListener { + override fun onForeground() { + sut.removeAppStateListener(this) + onForegroundCalled = true + } + + override fun onBackground() { + // ignored + } + } + + sut.registerLifecycleObserver(fixture.options) + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + + // if an observer is added + sut.addAppStateListener(listener) + + // it should be notified + assertTrue(onForegroundCalled) + + // and removed from the list of listeners if it unregisters itself within the callback + assertEquals(sut.lifecycleObserver?.listeners?.size, 0) + } + + @Test + fun `state is correct within onStart and onStop callbacks`() { + val sut = fixture.getSut() + + var onForegroundCalled = false + var onBackgroundCalled = false + val listener = + object : AppStateListener { + override fun onForeground() { + assertFalse(sut.isInBackground!!) + onForegroundCalled = true + } + + override fun onBackground() { + assertTrue(sut.isInBackground!!) + onBackgroundCalled = true + } + } + + sut.addAppStateListener(listener) + + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + observer.onStop(mock()) + + assertTrue(onForegroundCalled) + assertTrue(onBackgroundCalled) + } + + @Test + fun `thread safety - concurrent access is handled`() { + val listeners = (1..5).map { fixture.createListener() } + val sut = fixture.getSut() + val latch = CountDownLatch(5) + + // Add listeners concurrently + listeners.forEach { listener -> + Thread { + sut.addAppStateListener(listener) + latch.countDown() + } + .start() + } + latch.await() + + val observer = sut.lifecycleObserver + assertNotNull(observer) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 446dfa3330a..5149f167129 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -1,6 +1,5 @@ package io.sentry.android.core -import androidx.lifecycle.LifecycleOwner import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IContinuousProfiler @@ -16,10 +15,8 @@ import io.sentry.transport.ICurrentDateProvider import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -33,7 +30,6 @@ import org.mockito.kotlin.whenever class LifecycleWatcherTest { private class Fixture { - val ownerMock = mock() val scopes = mock() val dateProvider = mock() val options = SentryOptions() @@ -77,7 +73,7 @@ class LifecycleWatcherTest { @Test fun `if last started session is 0, start new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } @@ -86,8 +82,8 @@ class LifecycleWatcherTest { fun `if last started session is after interval, start new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L, 2L) - watcher.onStart(fixture.ownerMock) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() + watcher.onForeground() verify(fixture.scopes, times(2)).startSession() verify(fixture.replayController, times(2)).start() } @@ -96,8 +92,8 @@ class LifecycleWatcherTest { fun `if last started session is before interval, it should not start a new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) whenever(fixture.dateProvider.currentTimeMillis).thenReturn(2L, 1L) - watcher.onStart(fixture.ownerMock) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } @@ -105,8 +101,8 @@ class LifecycleWatcherTest { @Test fun `if app goes to background, end session after interval`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - watcher.onStop(fixture.ownerMock) + watcher.onForeground() + watcher.onBackground() verify(fixture.scopes, timeout(10000)).endSession() verify(fixture.replayController, timeout(10000)).stop() verify(fixture.continuousProfiler, timeout(10000)).close(eq(false)) @@ -116,12 +112,12 @@ class LifecycleWatcherTest { fun `if app goes to background and foreground again, dont end the session`() { val watcher = fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() assertNotNull(watcher.timerTask) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() assertNull(watcher.timerTask) verify(fixture.scopes, never()).endSession() @@ -132,7 +128,7 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not start session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).startSession() } @@ -140,14 +136,14 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not end session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes, never()).endSession() } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes) .addBreadcrumb( check { @@ -163,14 +159,14 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes) .addBreadcrumb( check { @@ -186,7 +182,7 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes, never()).addBreadcrumb(any()) } @@ -221,7 +217,7 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).startSession() verify(fixture.replayController, never()).start() } @@ -250,25 +246,11 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } - @Test - fun `When app goes into foreground, sets isBackground to false for AppState`() { - val watcher = fixture.getSUT() - watcher.onStart(fixture.ownerMock) - assertFalse(AppState.getInstance().isInBackground!!) - } - - @Test - fun `When app goes into background, sets isBackground to true for AppState`() { - val watcher = fixture.getSUT() - watcher.onStop(fixture.ownerMock) - assertTrue(AppState.getInstance().isInBackground!!) - } - @Test fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = @@ -293,7 +275,7 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController).resume() } @@ -301,16 +283,16 @@ class LifecycleWatcherTest { fun `background-foreground replay`() { whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) val watcher = fixture.getSUT(sessionIntervalMillis = 2L, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController).start() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.replayController).pause() - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController, times(2)).resume() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.replayController, timeout(10000)).stop() } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index d4264d9831b..bdb328e2421 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -18,7 +18,6 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents -import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext @@ -41,6 +40,7 @@ class SessionTrackingIntegrationTest { @BeforeTest fun `set up`() { + AppState.getInstance().resetInstance() context = ApplicationProvider.getApplicationContext() } @@ -56,7 +56,7 @@ class SessionTrackingIntegrationTest { } val client = CapturingSentryClient() Sentry.bindClient(client) - val lifecycle = setupLifecycle(options) + val lifecycle = setupLifecycle() val initSid = lastSessionId() lifecycle.handleLifecycleEvent(ON_START) @@ -115,12 +115,9 @@ class SessionTrackingIntegrationTest { return sid } - private fun setupLifecycle(options: SentryOptions): LifecycleRegistry { + private fun setupLifecycle(): LifecycleRegistry { val lifecycle = LifecycleRegistry(ProcessLifecycleOwner.get()) - val lifecycleWatcher = - (options.integrations.find { it is AppLifecycleIntegration } as AppLifecycleIntegration) - .watcher - lifecycle.addObserver(lifecycleWatcher!!) + lifecycle.addObserver(AppState.getInstance().lifecycleObserver) return lifecycle } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 650c36868ba..c156eafd1ef 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -13,11 +13,14 @@ import io.sentry.SentryLevel import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import java.util.concurrent.CountDownLatch +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -38,14 +41,11 @@ class SystemEventsBreadcrumbsIntegrationTest { val context = mock() var options = SentryAndroidOptions() val scopes = mock() - lateinit var handler: MainLooperHandler fun getSut( enableSystemEventBreadcrumbs: Boolean = true, executorService: ISentryExecutorService = ImmediateExecutorService(), - mockHandler: Boolean = true, ): SystemEventsBreadcrumbsIntegration { - handler = if (mockHandler) mock() else MainLooperHandler() options = SentryAndroidOptions().apply { isEnableSystemEventBreadcrumbs = enableSystemEventBreadcrumbs @@ -54,13 +54,23 @@ class SystemEventsBreadcrumbsIntegrationTest { return SystemEventsBreadcrumbsIntegration( context, SystemEventsBreadcrumbsIntegration.getDefaultActions().toTypedArray(), - handler, ) } } private val fixture = Fixture() + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + AppState.getInstance().registerLifecycleObserver(fixture.options) + } + + @AfterTest + fun `tear down`() { + AppState.getInstance().unregisterLifecycleObserver() + } + @Test fun `When system events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() @@ -337,82 +347,60 @@ class SystemEventsBreadcrumbsIntegrationTest { } @Test - fun `When integration is added, lifecycle handler should be started`() { + fun `When integration is added, should subscribe for app state events`() { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) + assertTrue( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test - fun `When system events breadcrumbs are disabled, lifecycle handler should not be started`() { + fun `When system events breadcrumbs are disabled, should not subscribe for app state events`() { val sut = fixture.getSut() fixture.options.apply { isEnableSystemEventBreadcrumbs = false } sut.register(fixture.scopes, fixture.options) - assertNull(sut.lifecycleHandler) + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test - fun `When integration is closed, lifecycle handler should be closed`() { + fun `When integration is closed, should unsubscribe from app state events`() { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) + assertTrue( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) sut.close() - assertNull(sut.lifecycleHandler) - } - - @Test - fun `When integration is registered from a background thread, post on the main thread`() { - val sut = fixture.getSut() - val latch = CountDownLatch(1) - - Thread { - sut.register(fixture.scopes, fixture.options) - latch.countDown() + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration } - .start() - - latch.await() - - verify(fixture.handler).post(any()) + ) } @Test - fun `When integration is closed from a background thread, post on the main thread`() { + fun `When integration is closed from a background thread, unsubscribes from app events`() { val sut = fixture.getSut() val latch = CountDownLatch(1) sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) - - Thread { - sut.close() - latch.countDown() - } - .start() - - latch.await() - - verify(fixture.handler).post(any()) - } - - @Test - fun `When integration is closed from a background thread, watcher is set to null`() { - val sut = fixture.getSut(mockHandler = false) - val latch = CountDownLatch(1) - - sut.register(fixture.scopes, fixture.options) - - assertNotNull(sut.lifecycleHandler) - Thread { sut.close() latch.countDown() @@ -424,7 +412,11 @@ class SystemEventsBreadcrumbsIntegrationTest { // ensure all messages on main looper got processed shadowOf(Looper.getMainLooper()).idle() - assertNull(sut.lifecycleHandler) + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test @@ -433,7 +425,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - sut.lifecycleHandler!!.onStop(mock()) + sut.onBackground() verify(fixture.context).unregisterReceiver(any()) assertNull(sut.receiver) @@ -446,8 +438,8 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) verify(fixture.context).registerReceiver(any(), any(), any()) - sut.lifecycleHandler!!.onStop(mock()) - sut.lifecycleHandler!!.onStart(mock()) + sut.onBackground() + sut.onForeground() verify(fixture.context, times(2)).registerReceiver(any(), any(), any()) assertNotNull(sut.receiver) @@ -461,7 +453,7 @@ class SystemEventsBreadcrumbsIntegrationTest { verify(fixture.context).registerReceiver(any(), any(), any()) val receiver = sut.receiver - sut.lifecycleHandler!!.onStart(mock()) + sut.onForeground() assertEquals(receiver, sut.receiver) } @@ -473,10 +465,11 @@ class SystemEventsBreadcrumbsIntegrationTest { deferredExecutorService.runAll() assertNotNull(sut.receiver) - sut.lifecycleHandler!!.onStop(mock()) - sut.lifecycleHandler!!.onStart(mock()) + sut.onBackground() + sut.onForeground() + deferredExecutorService.runAll() assertNull(sut.receiver) - sut.lifecycleHandler!!.onStop(mock()) + sut.onBackground() deferredExecutorService.runAll() assertNull(sut.receiver) } @@ -486,7 +479,7 @@ class SystemEventsBreadcrumbsIntegrationTest { val deferredExecutorService = DeferredExecutorService() val latch = CountDownLatch(1) - val sut = fixture.getSut(executorService = deferredExecutorService, mockHandler = false) + val sut = fixture.getSut(executorService = deferredExecutorService) sut.register(fixture.scopes, fixture.options) deferredExecutorService.runAll() assertNotNull(sut.receiver) @@ -498,14 +491,14 @@ class SystemEventsBreadcrumbsIntegrationTest { .start() latch.await() + deferredExecutorService.runAll() - sut.lifecycleHandler!!.onStart(mock()) + sut.onForeground() assertNull(sut.receiver) deferredExecutorService.runAll() shadowOf(Looper.getMainLooper()).idle() assertNull(sut.receiver) - assertNull(sut.lifecycleHandler) } } From 29f057b8d2b2266d32efd4ea00ab70434a4d6efe Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 5 Aug 2025 12:39:39 +0200 Subject: [PATCH 14/21] fix(sqlite): Fix abstract method error (#4597) * fix(sqlite): Fix abstract method error * Update CHANGELOG.md * Suppress metadata version checks --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 2 +- sentry-android-sqlite/build.gradle.kts | 9 ++++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3a1154a05..df0f8aaf492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ native ; } ``` +- Fix abstract method error in `SentrySupportSQLiteDatabase` ([#4597](https://github.com/getsentry/sentry-java/pull/4597)) ## 8.18.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d88dd71e208..4379c63d615 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,7 +79,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.3.1" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } coil-compose = { module = "io.coil-kt:coil-compose", version = "2.6.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.25.0"} diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 434841f6c31..2c0908bd4fa 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -54,7 +54,14 @@ android { } } -kotlin { explicitApi() } +kotlin { + explicitApi() + compilerOptions { + // skip metadata version check, as androidx.sqlite:sqlite is compiled against a newer version of + // Kotlin + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} dependencies { api(projects.sentry) From d4ba02bc3a8e5289b5a5b72b11a3f528bfe54e4f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 6 Aug 2025 11:53:51 +0200 Subject: [PATCH 15/21] perf(integrations): Do not register for SystemEvents and NetworkCallbacks when launched with background importance (#4579) --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 13 ++ .../java/io/sentry/android/core/AppState.java | 35 ++- .../io/sentry/android/core/ContextUtils.java | 26 +-- .../SystemEventsBreadcrumbsIntegration.java | 15 +- .../util/AndroidConnectionStatusProvider.java | 153 ++++++++---- .../SystemEventsBreadcrumbsIntegrationTest.kt | 25 ++ .../AndroidConnectionStatusProviderTest.kt | 219 +++++++++++++++++- .../sentry-samples-android/sdkperf/README.md | 78 +++++++ .../sentry-samples-android/sdkperf/basic.pbtx | 103 ++++++++ .../sdkperf/screen_flap.sh | 15 ++ .../sdkperf/wifi_flap.sh | 20 ++ .../src/main/AndroidManifest.xml | 11 +- .../sentry/samples/android/DummyService.java | 68 ++++++ 14 files changed, 697 insertions(+), 85 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/sdkperf/README.md create mode 100644 sentry-samples/sentry-samples-android/sdkperf/basic.pbtx create mode 100755 sentry-samples/sentry-samples-android/sdkperf/screen_flap.sh create mode 100755 sentry-samples/sentry-samples-android/sdkperf/wifi_flap.sh create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DummyService.java diff --git a/CHANGELOG.md b/CHANGELOG.md index df0f8aaf492..c617242fc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561)) - Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585)) - Have single `NetworkCallback` registered at a time to reduce IPC calls ([#4562](https://github.com/getsentry/sentry-java/pull/4562)) +- Do not register for SystemEvents and NetworkCallbacks immediately when launched with non-foreground importance ([#4579](https://github.com/getsentry/sentry-java/pull/4579)) - Limit ProGuard keep rules for native methods within `sentry-android-ndk` to the `io.sentry.**` namespace. ([#4427](https://github.com/getsentry/sentry-java/pull/4427)) - If you relied on the Sentry SDK to keep native method names for JNI compatibility within your namespace, please review your ProGuard rules and ensure the configuration still works. Especially when you're not consuming any of the default Android proguard rules (`proguard-android.txt` or `proguard-android-optimize.txt`) the following config should be present: ``` diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 044a1fded2b..6bcb961b321 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -167,9 +167,15 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In } public final class io/sentry/android/core/AppState : java/io/Closeable { + public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V public fun close ()V public static fun getInstance ()Lio/sentry/android/core/AppState; + public fun getLifecycleObserver ()Lio/sentry/android/core/AppState$LifecycleObserver; public fun isInBackground ()Ljava/lang/Boolean; + public fun registerLifecycleObserver (Lio/sentry/SentryOptions;)V + public fun removeAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V + public fun resetInstance ()V + public fun unregisterLifecycleObserver ()V } public abstract interface class io/sentry/android/core/AppState$AppStateListener { @@ -177,6 +183,13 @@ public abstract interface class io/sentry/android/core/AppState$AppStateListener public abstract fun onForeground ()V } +public final class io/sentry/android/core/AppState$LifecycleObserver : androidx/lifecycle/DefaultLifecycleObserver { + public fun (Lio/sentry/android/core/AppState;)V + public fun getListeners ()Ljava/util/List; + public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V + public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java index 8fc1c8ab0b8..74522f7aaac 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java @@ -2,13 +2,13 @@ import androidx.annotation.NonNull; import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.NoOpLogger; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.util.AutoClosableReentrantLock; import java.io.Closeable; @@ -36,21 +36,23 @@ private AppState() {} private volatile @Nullable Boolean inBackground = null; - @TestOnly - LifecycleObserver getLifecycleObserver() { - return lifecycleObserver; - } - @TestOnly void setHandler(final @NotNull MainLooperHandler handler) { this.handler = handler; } + @ApiStatus.Internal @TestOnly - void resetInstance() { + public void resetInstance() { instance = new AppState(); } + @ApiStatus.Internal + @TestOnly + public LifecycleObserver getLifecycleObserver() { + return lifecycleObserver; + } + public @Nullable Boolean isInBackground() { return inBackground; } @@ -59,7 +61,7 @@ void setInBackground(final boolean inBackground) { this.inBackground = inBackground; } - void addAppStateListener(final @NotNull AppStateListener listener) { + public void addAppStateListener(final @NotNull AppStateListener listener) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { ensureLifecycleObserver(NoOpLogger.getInstance()); @@ -69,7 +71,7 @@ void addAppStateListener(final @NotNull AppStateListener listener) { } } - void removeAppStateListener(final @NotNull AppStateListener listener) { + public void removeAppStateListener(final @NotNull AppStateListener listener) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (lifecycleObserver != null) { lifecycleObserver.listeners.remove(listener); @@ -77,7 +79,8 @@ void removeAppStateListener(final @NotNull AppStateListener listener) { } } - void registerLifecycleObserver(final @Nullable SentryAndroidOptions options) { + @ApiStatus.Internal + public void registerLifecycleObserver(final @Nullable SentryOptions options) { if (lifecycleObserver != null) { return; } @@ -133,7 +136,8 @@ private void addObserverInternal(final @NotNull ILogger logger) { } } - void unregisterLifecycleObserver() { + @ApiStatus.Internal + public void unregisterLifecycleObserver() { if (lifecycleObserver == null) { return; } @@ -167,7 +171,8 @@ public void close() throws IOException { unregisterLifecycleObserver(); } - final class LifecycleObserver implements DefaultLifecycleObserver { + @ApiStatus.Internal + public final class LifecycleObserver implements DefaultLifecycleObserver { final List listeners = new CopyOnWriteArrayList() { @Override @@ -184,6 +189,12 @@ public boolean add(AppStateListener appStateListener) { } }; + @ApiStatus.Internal + @TestOnly + public List getListeners() { + return listeners; + } + @Override public void onStart(@NonNull LifecycleOwner owner) { setInBackground(false); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 0f1c63ef278..8ba0e16b7c6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -21,7 +21,6 @@ import io.sentry.SentryOptions; import io.sentry.android.core.util.AndroidLazyEvaluator; import io.sentry.protocol.App; -import io.sentry.util.LazyEvaluator; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -91,20 +90,6 @@ private ContextUtils() {} // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static // during the app process running - private static final @NotNull LazyEvaluator isForegroundImportance = - new LazyEvaluator<>( - () -> { - try { - final ActivityManager.RunningAppProcessInfo appProcessInfo = - new ActivityManager.RunningAppProcessInfo(); - ActivityManager.getMyMemoryState(appProcessInfo); - return appProcessInfo.importance == IMPORTANCE_FOREGROUND; - } catch (Throwable ignored) { - // should never happen - } - return false; - }); - /** * Since this packageInfo uses flags 0 we can assume it's static and cache it as the package name * or version code cannot change during runtime, only after app update (which will spin up a new @@ -284,7 +269,15 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { */ @ApiStatus.Internal public static boolean isForegroundImportance() { - return isForegroundImportance.getValue(); + try { + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == IMPORTANCE_FOREGROUND; + } catch (Throwable ignored) { + // should never happen + } + return false; } /** @@ -544,7 +537,6 @@ public static Context getApplicationContext(final @NotNull Context context) { @TestOnly static void resetInstance() { - isForegroundImportance.resetValue(); staticPackageInfo33.resetValue(); staticPackageInfo.resetValue(); applicationName.resetValue(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index f8950ee7626..b08b429fdf2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -43,6 +43,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -62,6 +63,7 @@ public final class SystemEventsBreadcrumbsIntegration private volatile boolean isClosed = false; private volatile boolean isStopped = false; private volatile IntentFilter filter = null; + private final @NotNull AtomicBoolean isReceiverRegistered = new AtomicBoolean(false); private final @NotNull AutoClosableReentrantLock receiverLock = new AutoClosableReentrantLock(); // Track previous battery state to avoid duplicate breadcrumbs when values haven't changed private @Nullable BatteryState previousBatteryState; @@ -101,14 +103,15 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (this.options.isEnableSystemEventBreadcrumbs()) { AppState.getInstance().addAppStateListener(this); - registerReceiver(this.scopes, this.options, /* reportAsNewIntegration= */ true); + + if (ContextUtils.isForegroundImportance()) { + registerReceiver(this.scopes, this.options); + } } } private void registerReceiver( - final @NotNull IScopes scopes, - final @NotNull SentryAndroidOptions options, - final boolean reportAsNewIntegration) { + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { if (!options.isEnableSystemEventBreadcrumbs()) { return; @@ -139,7 +142,7 @@ private void registerReceiver( // registerReceiver can throw SecurityException but it's not documented in the // official docs ContextUtils.registerReceiver(context, options, receiver, filter); - if (reportAsNewIntegration) { + if (!isReceiverRegistered.getAndSet(true)) { options .getLogger() .log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); @@ -239,7 +242,7 @@ public void onForeground() { isStopped = false; - registerReceiver(scopes, options, /* reportAsNewIntegration= */ false); + registerReceiver(scopes, options); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index 16ccc0a2ee4..4e657c9c64c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -15,12 +15,14 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.AppState; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,7 +34,8 @@ * details */ @ApiStatus.Internal -public final class AndroidConnectionStatusProvider implements IConnectionStatusProvider { +public final class AndroidConnectionStatusProvider + implements IConnectionStatusProvider, AppState.AppStateListener { private final @NotNull Context context; private final @NotNull SentryOptions options; @@ -59,11 +62,11 @@ public final class AndroidConnectionStatusProvider implements IConnectionStatusP private static final int[] capabilities = new int[2]; - private final @NotNull Thread initThread; private volatile @Nullable NetworkCapabilities cachedNetworkCapabilities; private volatile @Nullable Network currentNetwork; private volatile long lastCacheUpdateTime = 0; private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes + private final @NotNull AtomicBoolean isConnected = new AtomicBoolean(false); @SuppressLint("InlinedApi") public AndroidConnectionStatusProvider( @@ -84,8 +87,9 @@ public AndroidConnectionStatusProvider( // Register network callback immediately for caching //noinspection Convert2MethodRef - initThread = new Thread(() -> ensureNetworkCallbackRegistered()); - initThread.start(); + submitSafe(() -> ensureNetworkCallbackRegistered()); + + AppState.getInstance().addAppStateListener(this); } /** @@ -152,6 +156,10 @@ private boolean isNetworkEffectivelyConnected( } private void ensureNetworkCallbackRegistered() { + if (!ContextUtils.isForegroundImportance()) { + return; + } + if (networkCallback != null) { return; // Already registered } @@ -167,9 +175,14 @@ private void ensureNetworkCallbackRegistered() { public void onAvailable(final @NotNull Network network) { currentNetwork = network; - try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { - for (final @NotNull NetworkCallback cb : childCallbacks) { - cb.onAvailable(network); + // have to only dispatch this on first registration + when the connection got + // re-established + // otherwise it would've been dispatched on every foreground + if (!isConnected.getAndSet(true)) { + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + cb.onAvailable(network); + } } } } @@ -201,6 +214,7 @@ public void onLost(final @NotNull Network network) { } private void clearCacheAndNotifyObservers() { + isConnected.set(false); // Clear cached capabilities and network reference atomically try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { cachedNetworkCapabilities = null; @@ -417,42 +431,90 @@ public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObser } } + private void unregisterNetworkCallback(final boolean clearObservers) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (clearObservers) { + connectionStatusObservers.clear(); + } + + final @Nullable NetworkCallback callbackRef = networkCallback; + networkCallback = null; + + if (callbackRef != null) { + unregisterNetworkCallback(context, options.getLogger(), callbackRef); + } + // Clear cached state + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = 0; + } + options.getLogger().log(SentryLevel.DEBUG, "Network callback unregistered"); + } + /** Clean up resources - should be called when the provider is no longer needed */ @Override public void close() { - try { - options - .getExecutorService() - .submit( - () -> { - final NetworkCallback callbackRef; - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - connectionStatusObservers.clear(); + submitSafe( + () -> { + unregisterNetworkCallback(/* clearObservers= */ true); + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + childCallbacks.clear(); + } + try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { + connectivityManager = null; + } + AppState.getInstance().removeAppStateListener(this); + }); + } - callbackRef = networkCallback; - networkCallback = null; + @Override + public void onForeground() { + if (networkCallback != null) { + return; + } - if (callbackRef != null) { - unregisterNetworkCallback(context, options.getLogger(), callbackRef); - } - // Clear cached state - cachedNetworkCapabilities = null; - currentNetwork = null; - lastCacheUpdateTime = 0; - } - try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { - childCallbacks.clear(); - } - try (final @NotNull ISentryLifecycleToken ignored = - connectivityManagerLock.acquire()) { - connectivityManager = null; - } - }); - } catch (Throwable t) { - options - .getLogger() - .log(SentryLevel.ERROR, "Error submitting AndroidConnectionStatusProvider task", t); + submitSafe( + () -> { + // proactively update cache and notify observers on foreground to ensure connectivity + // state is not stale + updateCache(null); + + final @NotNull ConnectionStatus status = getConnectionStatusFromCache(); + if (status == ConnectionStatus.DISCONNECTED) { + // onLost is not called retroactively when we registerNetworkCallback (as opposed to + // onAvailable), so we have to do it manually for the DISCONNECTED case + isConnected.set(false); + try (final @NotNull ISentryLifecycleToken ignored = childCallbacksLock.acquire()) { + for (final @NotNull NetworkCallback cb : childCallbacks) { + //noinspection DataFlowIssue + cb.onLost(null); + } + } + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : connectionStatusObservers) { + observer.onConnectionStatusChanged(status); + } + } + + // this will ONLY do the necessary parts like registerNetworkCallback and onAvailable, but + // it won't updateCache and notify observes because we just did it above and the cached + // capabilities will be the same + ensureNetworkCallbackRegistered(); + }); + } + + @Override + public void onBackground() { + if (networkCallback == null) { + return; } + + submitSafe( + () -> { + //noinspection Convert2MethodRef + unregisterNetworkCallback(/* clearObservers= */ false); + }); } /** @@ -599,7 +661,6 @@ public NetworkCapabilities getCachedNetworkCapabilities() { if (cellular) { return "cellular"; } - } catch (Throwable exception) { logger.log(SentryLevel.ERROR, "Failed to retrieve network info", exception); } @@ -734,15 +795,19 @@ public NetworkCallback getNetworkCallback() { return networkCallback; } - @TestOnly - @NotNull - public Thread getInitThread() { - return initThread; - } - @TestOnly @NotNull public static List getChildCallbacks() { return childCallbacks; } + + private void submitSafe(@NotNull Runnable r) { + try { + options.getExecutorService().submit(r); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "AndroidConnectionStatusProvider submit failed", e); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index c156eafd1ef..1c17ac7c65b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -1,10 +1,13 @@ package io.sentry.android.core +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.content.Context import android.content.Intent import android.os.BatteryManager import android.os.Build import android.os.Looper +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IScopes @@ -33,6 +36,9 @@ import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowBuild @RunWith(AndroidJUnit4::class) @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) @@ -41,6 +47,7 @@ class SystemEventsBreadcrumbsIntegrationTest { val context = mock() var options = SentryAndroidOptions() val scopes = mock() + lateinit var shadowActivityManager: ShadowActivityManager fun getSut( enableSystemEventBreadcrumbs: Boolean = true, @@ -64,6 +71,11 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `set up`() { AppState.getInstance().resetInstance() AppState.getInstance().registerLifecycleObserver(fixture.options) + ShadowBuild.reset() + val activityManager = + ApplicationProvider.getApplicationContext() + .getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) } @AfterTest @@ -501,4 +513,17 @@ class SystemEventsBreadcrumbsIntegrationTest { assertNull(sut.receiver) } + + @Test + fun `when integration is registered in background, receiver is not registered`() { + val process = + RunningAppProcessInfo().apply { this.importance = RunningAppProcessInfo.IMPORTANCE_CACHED } + val processes = mutableListOf(process) + fixture.shadowActivityManager.setProcesses(processes) + + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + assertNull(sut.receiver) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 6769ac15301..7d27984a599 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -15,10 +15,14 @@ import android.net.NetworkCapabilities.TRANSPORT_ETHERNET import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkInfo import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.SentryOptions +import io.sentry.android.core.AppState import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.ContextUtils +import io.sentry.android.core.SystemEventsBreadcrumbsIntegration import io.sentry.test.ImmediateExecutorService import io.sentry.transport.ICurrentDateProvider import kotlin.test.AfterTest @@ -29,8 +33,13 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails @@ -39,7 +48,10 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.P]) class AndroidConnectionStatusProviderTest { private lateinit var connectionStatusProvider: AndroidConnectionStatusProvider private lateinit var contextMock: Context @@ -51,6 +63,7 @@ class AndroidConnectionStatusProviderTest { private lateinit var network: Network private lateinit var networkCapabilities: NetworkCapabilities private lateinit var logger: ILogger + private lateinit var contextUtilsStaticMock: MockedStatic private var currentTime = 1000L @@ -91,17 +104,28 @@ class AndroidConnectionStatusProviderTest { // Reset current time for each test to ensure cache isolation currentTime = 1000L + // Mock ContextUtils to return foreground importance + contextUtilsStaticMock = mockStatic(ContextUtils::class.java) + contextUtilsStaticMock + .`when` { ContextUtils.isForegroundImportance() } + .thenReturn(true) + contextUtilsStaticMock + .`when` { ContextUtils.getApplicationContext(any()) } + .thenReturn(contextMock) + + AppState.getInstance().resetInstance() + AppState.getInstance().registerLifecycleObserver(options) + connectionStatusProvider = AndroidConnectionStatusProvider(contextMock, options, buildInfo, timeProvider) - - // Wait for async callback registration to complete - connectionStatusProvider.initThread.join() } @AfterTest fun `tear down`() { // clear the cache and ensure proper cleanup connectionStatusProvider.close() + contextUtilsStaticMock.close() + AppState.getInstance().unregisterLifecycleObserver() } @Test @@ -161,10 +185,14 @@ class AndroidConnectionStatusProviderTest { ) .thenReturn(PERMISSION_GRANTED) + // Need to mock ContextUtils for the new provider as well + contextUtilsStaticMock + .`when` { ContextUtils.getApplicationContext(eq(nullConnectivityContext)) } + .thenReturn(nullConnectivityContext) + // Create a new provider with the null connectivity manager val providerWithNullConnectivity = AndroidConnectionStatusProvider(nullConnectivityContext, options, buildInfo, timeProvider) - providerWithNullConnectivity.initThread.join() // Wait for async init to complete assertEquals( IConnectionStatusProvider.ConnectionStatus.UNKNOWN, @@ -443,7 +471,7 @@ class AndroidConnectionStatusProviderTest { verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) val callback = callbackCaptor.firstValue - // Set current network + // IMPORTANT: Set network as current first callback.onAvailable(network) // Lose the network @@ -632,4 +660,185 @@ class AndroidConnectionStatusProviderTest { mainCallback.onAvailable(network) verifyNoMoreInteractions(childCallback) } + + @Test + fun `onForeground notifies child callbacks when disconnected`() { + val childCallback = mock() + AndroidConnectionStatusProvider.addNetworkCallback( + contextMock, + logger, + buildInfo, + childCallback, + ) + connectionStatusProvider.onBackground() + + // Setup disconnected state + whenever(connectivityManager.activeNetwork).thenReturn(null) + + connectionStatusProvider.onForeground() + + // Verify child callback was notified of lost connection with any network parameter + verify(childCallback).onLost(anyOrNull()) + } + + @Test + fun `close removes AppState listener`() { + // Clear any setup interactions + clearInvocations(connectivityManager) + + // Close the provider + connectionStatusProvider.close() + + // Now test that after closing, the provider no longer responds to lifecycle events + connectionStatusProvider.onForeground() + connectionStatusProvider.onBackground() + + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) + } + + @Test + fun `network callbacks work correctly across foreground background transitions`() { + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + // Get the registered callback + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // Simulate network available + callback.onAvailable(network) + + // Go to background + connectionStatusProvider.onBackground() + + // Clear the mock to reset interaction count + clearInvocations(connectivityManager) + + // Go back to foreground + connectionStatusProvider.onForeground() + + // Verify callback was re-registered + verify(connectivityManager).registerDefaultNetworkCallback(any()) + + // Verify we can still receive network events + val newCallbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(newCallbackCaptor.capture()) + val newCallback = newCallbackCaptor.lastValue + + // Simulate network capabilities change + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(true) + + // First make the network available to set it as current + newCallback.onAvailable(network) + + // Then change capabilities + newCallback.onCapabilitiesChanged(network, newCaps) + + // Verify observer was notified (once for onForeground status update, once for capabilities + // change) + verify(observer, times(2)).onConnectionStatusChanged(any()) + } + + @Test + fun `onForeground registers network callback if not already registered`() { + // First ensure the network callback is not registered (simulate background state) + connectionStatusProvider.onBackground() + + // Verify callback was unregistered + assertNull(connectionStatusProvider.networkCallback) + + // Call onForeground + connectionStatusProvider.onForeground() + + // Verify network callback was registered + assertNotNull(connectionStatusProvider.networkCallback) + verify(connectivityManager, times(2)).registerDefaultNetworkCallback(any()) + } + + @Test + fun `onForeground updates cache and notifies observers`() { + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + // Simulate going to background first + connectionStatusProvider.onBackground() + + // Reset mock to clear previous interactions + whenever(connectivityManager.activeNetwork).thenReturn(network) + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(networkCapabilities.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + + // Call onForeground + connectionStatusProvider.onForeground() + + // Verify observer was notified with current status + verify(observer).onConnectionStatusChanged(IConnectionStatusProvider.ConnectionStatus.CONNECTED) + } + + @Test + fun `onForeground does nothing if callback already registered`() { + // Ensure callback is already registered + assertNotNull(connectionStatusProvider.networkCallback) + val initialCallback = connectionStatusProvider.networkCallback + + // Call onForeground + connectionStatusProvider.onForeground() + + // Verify callback hasn't changed + assertEquals(initialCallback, connectionStatusProvider.networkCallback) + // Verify registerDefaultNetworkCallback was only called once (during construction) + verify(connectivityManager, times(1)).registerDefaultNetworkCallback(any()) + } + + @Test + fun `onBackground unregisters network callback`() { + // Ensure callback is registered + assertNotNull(connectionStatusProvider.networkCallback) + + // Call onBackground + connectionStatusProvider.onBackground() + + // Verify callback was unregistered + assertNull(connectionStatusProvider.networkCallback) + verify(connectivityManager).unregisterNetworkCallback(any()) + } + + @Test + fun `onBackground does not clear observers`() { + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + // Call onBackground + connectionStatusProvider.onBackground() + + // Verify observer is still registered + assertEquals(1, connectionStatusProvider.statusObservers.size) + assertTrue(connectionStatusProvider.statusObservers.contains(observer)) + } + + @Test + fun `onBackground does nothing if callback not registered`() { + // First unregister by going to background + connectionStatusProvider.onBackground() + assertNull(connectionStatusProvider.networkCallback) + + // Reset mock to clear previous interactions + clearInvocations(connectivityManager) + + // Call onBackground again + connectionStatusProvider.onBackground() + + // Verify no additional unregister calls + verifyNoInteractions(connectivityManager) + } } diff --git a/sentry-samples/sentry-samples-android/sdkperf/README.md b/sentry-samples/sentry-samples-android/sdkperf/README.md new file mode 100644 index 00000000000..a350ec0d80b --- /dev/null +++ b/sentry-samples/sentry-samples-android/sdkperf/README.md @@ -0,0 +1,78 @@ +This folder contains various artifacts and info related to testing SDK performance and behaviour under different circumstances. + +## Perfetto + +The `basic.pbtx` file contains a perfetto config which covers some basic data sources and things that you usually would be interested in while experimenting with the SDK. + +You can adjust some certain things like `duration_ms` to make the trace last longer or add additional [data sources](https://perfetto.dev/docs/data-sources/atrace). + +To run it, ensure you have a device available via `adb` and then run: + +```bash +adb shell perfetto \ + -c basic.pbtx --txt \ + -o /data/misc/perfetto-traces/trace +``` + +And then perform various activities you're interested in. After the trace has finished, you can pull it: + +```bash +adb pull /data/misc/perfetto-traces/trace +``` + +And open it up in https://ui.perfetto.dev/. + +## Network Connectivity + +Android has a weird behavior which has been fixed in [Android 15](https://cs.android.com/android/_/android/platform/packages/modules/Connectivity/+/2d78124348f4864d054ea7a7b52683d225bd7c1f), where it would queue up pending NetworkCallbacks while an app is being frozen and would deliver **all** of them after the app unfreezes in a quick succession. + +Since our SDK is listening to NetworkCallbacks in [AndroidConnectionStatusProvider](../../../sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java) to determine current network connectivity status and to create breadcrumbs, our SDK can be burst with potentially hundreds or thousands of events after hours or days of the hosting app inactivity. + +The following steps are necessary to reproduce the issue: + +1. Launch the sample app and send it to background +2. Freeze it with `adb shell am freeze --sticky io.sentry.samples.android` +3. Run the `./wifi_flap` script which looses and obtains network connectivity 10 times. +4. Unfreeze the app with `adb shell am unfreeze io.sentry.samples.android` + +You can either watch Logcat or better start a Perfetto trace beforehand and then open it and observe the number of binder calls our SDK is doing to the Connectivity service. + +### Solution + +We have addressed the issue in [#4579](https://github.com/getsentry/sentry-java/pull/4579) by unsubscribing from network connectivity updates when app goes to background and re-subscribing again on foreground. + +## System Events + +[Android 14](https://developer.android.com/develop/background-work/background-tasks/broadcasts#android-14) introduced a new behavior that defers any system broadcasts while an app is in a cached state. These pending broadcasts will be delivered to the app once it gets uncached in a quick succession. + +Our SDK is listening to a bunch of broadcasts in [SystemEventsBreadcrumbsIntegration](../../../sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java) to create breadcrumbs, the SDK can be burst with potentially hundreds or thousands of pending broadcasts after hours or days of the hosting app inactivity. + +The following steps are necessary to reproduce the issue: + +1. Launch the sample app and send it to background +2. Freeze it with `adb shell am freeze --sticky io.sentry.samples.android` +3. Run the `./screen_flap` script which turns the screen on and off 10 times. +4. Unfreeze the app with `adb shell am unfreeze io.sentry.samples.android` + +And watch Logcat for a bunch of `SCREEN_OFF`/`SCREEN_ON` breadcrumbs created microseconds apart. + +### Solution + +We have addressed the issue in [#4338](https://github.com/getsentry/sentry-java/pull/4338) by unregistering the `BroadcastReceiver` when app goes to background and registering it again on foreground. + +## App Launch with Background Importance + +While the above two issues can be fixed by observing the App lifecycle, they still may become a problem if the app process has been launched with non-foreground importance (e.g. received a push notification). In this case our SDK would be initialized too and would still subscribe for SystemEvents and NetworkCallbacks while in background. + +The following steps are necessary to reproduce the issue: + +1. Launch the sample app +2. Kill it with `adb shell am force-stop io.sentry.samples.android` +3. Now launch a dummy service with `adb shell am start-foreground-service -n io.sentry.samples.android/io.sentry.samples.android.DummyService`. This ensures the app process is run with non-foreground importance. +4. Follow any of the steps described in the sections above. + +Observe (Logcat or Perfetto) that the faulty behaviours are still reproducible. + +### Solution + +We have addressed the issue in [#4579](https://github.com/getsentry/sentry-java/pull/4579) by not registering any of the offending integrations when the hosting app process is launched with non-foreground `importance`. We still keep observing the App Lifecycle to ensure we register the integrations when the App has been brought to foreground. \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/sdkperf/basic.pbtx b/sentry-samples/sentry-samples-android/sdkperf/basic.pbtx new file mode 100644 index 00000000000..20f599b9b06 --- /dev/null +++ b/sentry-samples/sentry-samples-android/sdkperf/basic.pbtx @@ -0,0 +1,103 @@ +buffers { + size_kb: 265536 + fill_policy: DISCARD +} +buffers { + size_kb: 4096 + fill_policy: DISCARD +} +data_sources { + config { + name: "linux.ftrace" + ftrace_config { + ftrace_events: "sched/sched_process_exit" + ftrace_events: "sched/sched_process_free" + ftrace_events: "task/task_newtask" + ftrace_events: "task/task_rename" + ftrace_events: "sched/sched_switch" + ftrace_events: "power/suspend_resume" + ftrace_events: "sched/sched_blocked_reason" + ftrace_events: "sched/sched_wakeup" + ftrace_events: "sched/sched_wakeup_new" + ftrace_events: "sched/sched_waking" + ftrace_events: "sched/sched_process_exit" + ftrace_events: "sched/sched_process_free" + ftrace_events: "task/task_newtask" + ftrace_events: "task/task_rename" + ftrace_events: "power/cpu_frequency" + ftrace_events: "power/cpu_idle" + ftrace_events: "power/suspend_resume" + ftrace_events: "power/gpu_frequency" + ftrace_events: "power/gpu_work_period" + ftrace_events: "ftrace/print" + atrace_categories: "adb" + atrace_categories: "camera" + atrace_categories: "gfx" + atrace_categories: "network" + atrace_categories: "power" + atrace_categories: "wm" + atrace_categories: "am" + atrace_categories: "dalvik" + atrace_categories: "bionic" + atrace_categories: "binder_driver" + atrace_categories: "binder_lock" + atrace_categories: "ss" + atrace_apps: "*" + + symbolize_ksyms: true + } + } +} +data_sources { + config { + name: "linux.process_stats" + process_stats_config { + scan_all_processes_on_start: true + proc_stats_poll_ms: 250 + } + } +} +data_sources { + config { + name: "linux.sys_stats" + } +} +data_sources { + config { + name: "android.log" + android_log_config { + } + } +} +data_sources { + config { + name: "android.surfaceflinger.frametimeline" + } +} +data_sources { + config { + name: "linux.perf" + perf_event_config { + remote_descriptor_timeout_ms: 10000 + ring_buffer_pages: 8192 + + timebase { + period: 1 + + tracepoint { + #name: "binder/binder_transaction" + name: "binder/binder_transaction_received" + } + } + callstack_sampling { + scope { + target_cmdline: "io.sentry.samples.android" + } + kernel_frames: true + } + } + } +} + + +duration_ms: 100000 \ No newline at end of file diff --git a/sentry-samples/sentry-samples-android/sdkperf/screen_flap.sh b/sentry-samples/sentry-samples-android/sdkperf/screen_flap.sh new file mode 100755 index 00000000000..828d0deeef4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/sdkperf/screen_flap.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Loop 4 times to toggle Screen +for i in {1..4} +do + echo "[$i] Turning screen off..." + adb shell input keyevent 223 + sleep 5 + + echo "[$i] Turning screen on..." + adb shell input keyevent 224 + sleep 5 +done + +echo "Done flapping Screen 4 times." diff --git a/sentry-samples/sentry-samples-android/sdkperf/wifi_flap.sh b/sentry-samples/sentry-samples-android/sdkperf/wifi_flap.sh new file mode 100755 index 00000000000..a468f5be477 --- /dev/null +++ b/sentry-samples/sentry-samples-android/sdkperf/wifi_flap.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Disable mobile data first +echo "Disabling mobile data..." +adb shell svc data disable + +# Loop 8 times to toggle Wi-Fi +for i in {1..8} +do + echo "[$i] Disabling Wi-Fi..." + adb shell svc wifi disable + sleep 2 + + echo "[$i] Enabling Wi-Fi..." + adb shell svc wifi enable + sleep 6 +done +# Turn mobile data back on +adb shell svc data enable +echo "Done flapping Wi-Fi 8 times." diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index d26b2a485e4..9d084ed97e1 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -12,7 +12,10 @@ - + + + + @@ -29,6 +32,12 @@ android:networkSecurityConfig="@xml/network" tools:ignore="GoogleAppIndexingWarning, UnusedAttribute"> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DummyService.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DummyService.java new file mode 100644 index 00000000000..b1d936e0cc6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DummyService.java @@ -0,0 +1,68 @@ +package io.sentry.samples.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +public class DummyService extends Service { + + private static final String TAG = "DummyService"; + private static final String CHANNEL_ID = "dummy_service_channel"; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "DummyService created"); + createNotificationChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "DummyService started"); + + Notification notification = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notification = + new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Dummy Service Running") + .setContentText("Used for background broadcast testing.") + .setSmallIcon(android.R.drawable.ic_menu_info_details) + .build(); + } + + if (notification != null) { + startForeground(1, notification); + } + + // You can stop immediately or keep running + // stopSelf(); + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "DummyService destroyed"); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = + new NotificationChannel( + CHANNEL_ID, "Dummy Service Channel", NotificationManager.IMPORTANCE_LOW); + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(channel); + } + } +} From 4425a1bba16887568f96951a2b2fb91ab5628590 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 7 Aug 2025 14:09:42 +0200 Subject: [PATCH 16/21] fix(android): Ensure frame metrics listeners are registered/unregistered on the main thread (#4582) * fix(android): Ensure frame metrics listeners are registered/unregistered on the main thread * Fix race conditions * Update Changelog * Update CHANGELOG.md * Address PR feedback --- CHANGELOG.md | 1 + .../util/SentryFrameMetricsCollector.java | 57 +++++++++++----- .../util/SentryFrameMetricsCollectorTest.kt | 66 +++++++++++++++++++ 3 files changed, 106 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c617242fc89..a7b1c62f37f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ } ``` - Fix abstract method error in `SentrySupportSQLiteDatabase` ([#4597](https://github.com/getsentry/sentry-java/pull/4597)) +- Ensure frame metrics listeners are registered/unregistered on the main thread ([#4582](https://github.com/getsentry/sentry-java/pull/4582)) ## 8.18.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index fed9dd5b2d0..55342c0e4c0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -38,6 +38,7 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); + private final @NotNull ILogger logger; private @Nullable Handler handler; private @Nullable WeakReference currentWindow; @@ -282,17 +283,20 @@ public void stopCollection(final @Nullable String listenerId) { @SuppressLint("NewApi") private void stopTrackingWindow(final @NotNull Window window) { - if (trackedWindows.contains(window)) { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { - try { - windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( - window, frameMetricsAvailableListener); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e); - } - } - trackedWindows.remove(window); - } + new Handler(Looper.getMainLooper()) + .post( + () -> { + try { + // Re-check if we should still remove the listener for this window + // in case trackCurrentWindow was called in the meantime + if (trackedWindows.remove(window)) { + windowFrameMetricsManager.removeOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener); + } + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to remove frameMetricsAvailableListener", e); + } + }); } private void setCurrentWindow(final @NotNull Window window) { @@ -305,18 +309,29 @@ private void setCurrentWindow(final @NotNull Window window) { @SuppressLint("NewApi") private void trackCurrentWindow() { - Window window = currentWindow != null ? currentWindow.get() : null; + @Nullable Window window = currentWindow != null ? currentWindow.get() : null; if (window == null || !isAvailable) { return; } - if (!trackedWindows.contains(window) && !listenerMap.isEmpty()) { + if (listenerMap.isEmpty()) { + return; + } - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N && handler != null) { - trackedWindows.add(window); - windowFrameMetricsManager.addOnFrameMetricsAvailableListener( - window, frameMetricsAvailableListener, handler); - } + if (handler != null) { + // Ensure the addOnFrameMetricsAvailableListener is called on the main thread + new Handler(Looper.getMainLooper()) + .post( + () -> { + if (trackedWindows.add(window)) { + try { + windowFrameMetricsManager.addOnFrameMetricsAvailableListener( + window, frameMetricsAvailableListener, handler); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Failed to add frameMetricsAvailableListener", e); + } + } + }); } } @@ -373,6 +388,9 @@ default void addOnFrameMetricsAvailableListener( final @NotNull Window window, final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener, final @Nullable Handler handler) { + if (frameMetricsAvailableListener == null) { + return; + } window.addOnFrameMetricsAvailableListener(frameMetricsAvailableListener, handler); } @@ -380,6 +398,9 @@ default void addOnFrameMetricsAvailableListener( default void removeOnFrameMetricsAvailableListener( final @NotNull Window window, final @Nullable Window.OnFrameMetricsAvailableListener frameMetricsAvailableListener) { + if (frameMetricsAvailableListener == null) { + return; + } window.removeOnFrameMetricsAvailableListener(frameMetricsAvailableListener); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index 3f8fea1b935..b3b018e87b2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -148,6 +148,9 @@ class SentryFrameMetricsCollectorTest { collector.startCollection(mock()) assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) collector.onActivityStarted(fixture.activity) + // Execute pending main looper tasks since addOnFrameMetricsAvailableListener is posted to main + // thread + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) } @@ -157,8 +160,12 @@ class SentryFrameMetricsCollectorTest { collector.startCollection(mock()) collector.onActivityStarted(fixture.activity) + // Execute pending add operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) collector.onActivityStopped(fixture.activity) + // Execute pending remove operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) } @@ -181,6 +188,8 @@ class SentryFrameMetricsCollectorTest { collector.onActivityStarted(fixture.activity) assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) collector.startCollection(mock()) + // Execute pending main looper tasks + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) } @@ -189,9 +198,13 @@ class SentryFrameMetricsCollectorTest { val collector = fixture.getSut(context) val id = collector.startCollection(mock()) collector.onActivityStarted(fixture.activity) + // Execute pending add operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) collector.stopCollection(id) + // Execute pending remove operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) } @@ -205,9 +218,13 @@ class SentryFrameMetricsCollectorTest { collector.onActivityStarted(fixture.activity) collector.onActivityStarted(fixture.activity) + // Execute pending add operations + Shadows.shadowOf(Looper.getMainLooper()).idle() collector.onActivityStopped(fixture.activity) collector.onActivityStopped(fixture.activity) + // Execute pending remove operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) @@ -228,9 +245,13 @@ class SentryFrameMetricsCollectorTest { collector.startCollection(mock()) collector.onActivityStarted(fixture.activity) collector.onActivityStarted(fixture.activity2) + // Execute pending add operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(2, fixture.addOnFrameMetricsAvailableListenerCounter) collector.onActivityStopped(fixture.activity) collector.onActivityStopped(fixture.activity2) + // Execute pending remove operations + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(2, fixture.removeOnFrameMetricsAvailableListenerCounter) } @@ -240,10 +261,13 @@ class SentryFrameMetricsCollectorTest { val id1 = collector.startCollection(mock()) val id2 = collector.startCollection(mock()) collector.onActivityStarted(fixture.activity) + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) collector.stopCollection(id1) + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) collector.stopCollection(id2) + Shadows.shadowOf(Looper.getMainLooper()).idle() assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) } @@ -511,6 +535,48 @@ class SentryFrameMetricsCollectorTest { ) } + @Test + fun `collector calls addOnFrameMetricsAvailableListener on main thread`() { + val collector = fixture.getSut(context) + + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + + assertEquals(0, fixture.addOnFrameMetricsAvailableListenerCounter) + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector calls removeOnFrameMetricsAvailableListener on main thread`() { + val collector = fixture.getSut(context) + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + collector.onActivityStopped(fixture.activity) + assertEquals(0, fixture.removeOnFrameMetricsAvailableListenerCounter) + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + } + + @Test + fun `collector prevents race condition when stop is called immediately after start`() { + val collector = fixture.getSut(context) + + collector.startCollection(mock()) + collector.onActivityStarted(fixture.activity) + collector.onActivityStopped(fixture.activity) + + // Now execute all pending operations + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // as the listeners are posted to the main thread, we expect an add followed by a remove + assertEquals(1, fixture.addOnFrameMetricsAvailableListenerCounter) + assertEquals(1, fixture.removeOnFrameMetricsAvailableListenerCounter) + assertEquals(0, collector.getProperty>("trackedWindows").size) + } + private fun createMockWindow(refreshRate: Float = 60F): Window { val mockWindow = mock() val mockDisplay = mock() From 2c7271afa9442f198f33a009d8529a64a5d219fc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 7 Aug 2025 15:37:31 +0200 Subject: [PATCH 17/21] perf(executor): Prewarm SentryExecutorService (#4606) --- CHANGELOG.md | 1 + .../io/sentry/android/core/ContextUtils.java | 15 +- .../sentry/android/core/DeviceInfoUtil.java | 2 +- .../sentry/android/core/LifecycleWatcher.java | 29 ++-- .../SystemEventsBreadcrumbsIntegration.java | 39 +++-- .../android/core/AndroidProfilerTest.kt | 2 + .../core/AndroidTransactionProfilerTest.kt | 2 + .../sentry/android/core/ContextUtilsTest.kt | 16 +- .../SystemEventsBreadcrumbsIntegrationTest.kt | 12 +- .../api/sentry-test-support.api | 2 + .../src/main/kotlin/io/sentry/test/Mocks.kt | 4 + sentry/api/sentry.api | 3 + sentry/build.gradle.kts | 1 + .../io/sentry/ISentryExecutorService.java | 6 + sentry/src/main/java/io/sentry/NoOpScope.java | 6 +- .../src/main/java/io/sentry/NoOpScopes.java | 6 +- .../io/sentry/NoOpSentryExecutorService.java | 3 + sentry/src/main/java/io/sentry/Sentry.java | 3 +- .../java/io/sentry/SentryExecutorService.java | 116 ++++++++++++- .../main/java/io/sentry/SentryOptions.java | 3 +- .../java/io/sentry/SpotlightIntegration.java | 2 +- .../sentry/logger/LoggerBatchProcessor.java | 2 +- .../io/sentry/SentryExecutorServiceTest.kt | 164 +++++++++++++++--- 23 files changed, 357 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b1c62f37f..ec13dc20e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) - Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567)) +- Prewarm `SentryExecutorService` for better performance at runtime ([#4606](https://github.com/getsentry/sentry-java/pull/4606)) ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 8ba0e16b7c6..60ae00f2ead 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -15,6 +15,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; +import android.os.Handler; import android.util.DisplayMetrics; import io.sentry.ILogger; import io.sentry.SentryLevel; @@ -455,8 +456,10 @@ public static boolean appIsLibraryForComposePreview(final @NotNull Context conte final @NotNull Context context, final @NotNull SentryOptions options, final @Nullable BroadcastReceiver receiver, - final @NotNull IntentFilter filter) { - return registerReceiver(context, new BuildInfoProvider(options.getLogger()), receiver, filter); + final @NotNull IntentFilter filter, + final @Nullable Handler handler) { + return registerReceiver( + context, new BuildInfoProvider(options.getLogger()), receiver, filter, handler); } /** Register an exported BroadcastReceiver, independently from platform version. */ @@ -465,15 +468,17 @@ public static boolean appIsLibraryForComposePreview(final @NotNull Context conte final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @Nullable BroadcastReceiver receiver, - final @NotNull IntentFilter filter) { + final @NotNull IntentFilter filter, + final @Nullable Handler handler) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { // From https://developer.android.com/guide/components/broadcasts#context-registered-receivers // If this receiver is listening for broadcasts sent from the system or from other apps, even // other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is // listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag. - return context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); + return context.registerReceiver( + receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED); } else { - return context.registerReceiver(receiver, filter); + return context.registerReceiver(receiver, filter, null, handler); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 5baf6e2a8d2..7ba321426a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -275,7 +275,7 @@ private Date getBootTime() { @Nullable private Intent getBatteryIntent() { return ContextUtils.registerReceiver( - context, buildInfoProvider, null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + context, buildInfoProvider, null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED), null); } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index a5e4d398e74..3d4cedb1b53 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -8,6 +8,7 @@ import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.atomic.AtomicLong; @@ -22,7 +23,7 @@ final class LifecycleWatcher implements AppState.AppStateListener { private final long sessionIntervalMillis; private @Nullable TimerTask timerTask; - private final @NotNull Timer timer = new Timer(true); + private final @NotNull LazyEvaluator timer = new LazyEvaluator<>(() -> new Timer(true)); private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); private final @NotNull IScopes scopes; private final boolean enableSessionTracking; @@ -105,21 +106,19 @@ public void onBackground() { private void scheduleEndSession() { try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { cancelTask(); - if (timer != null) { - timerTask = - new TimerTask() { - @Override - public void run() { - if (enableSessionTracking) { - scopes.endSession(); - } - scopes.getOptions().getReplayController().stop(); - scopes.getOptions().getContinuousProfiler().close(false); + timerTask = + new TimerTask() { + @Override + public void run() { + if (enableSessionTracking) { + scopes.endSession(); } - }; + scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); + } + }; - timer.schedule(timerTask, sessionIntervalMillis); - } + timer.getValue().schedule(timerTask, sessionIntervalMillis); } } @@ -152,6 +151,6 @@ TimerTask getTimerTask() { @TestOnly @NotNull Timer getTimer() { - return timer; + return timer.getValue(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index b08b429fdf2..91099d01253 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -25,6 +25,9 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Process; import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IScopes; @@ -63,6 +66,7 @@ public final class SystemEventsBreadcrumbsIntegration private volatile boolean isClosed = false; private volatile boolean isStopped = false; private volatile IntentFilter filter = null; + private volatile HandlerThread handlerThread = null; private final @NotNull AtomicBoolean isReceiverRegistered = new AtomicBoolean(false); private final @NotNull AutoClosableReentrantLock receiverLock = new AutoClosableReentrantLock(); // Track previous battery state to avoid duplicate breadcrumbs when values haven't changed @@ -138,10 +142,19 @@ private void registerReceiver( filter.addAction(item); } } + if (handlerThread == null) { + handlerThread = + new HandlerThread( + "SystemEventsReceiver", Process.THREAD_PRIORITY_BACKGROUND); + handlerThread.start(); + } try { // registerReceiver can throw SecurityException but it's not documented in the // official docs - ContextUtils.registerReceiver(context, options, receiver, filter); + + // onReceive will be called on this handler thread + final @NotNull Handler handler = new Handler(handlerThread.getLooper()); + ContextUtils.registerReceiver(context, options, receiver, filter, handler); if (!isReceiverRegistered.getAndSet(true)) { options .getLogger() @@ -195,6 +208,10 @@ public void close() throws IOException { try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { isClosed = true; filter = null; + if (handlerThread != null) { + handlerThread.quit(); + } + handlerThread = null; } AppState.getInstance().removeAppStateListener(this); @@ -293,25 +310,15 @@ public void onReceive(final Context context, final @NotNull Intent intent) { final BatteryState state = batteryState; final long now = System.currentTimeMillis(); - try { - options - .getExecutorService() - .submit( - () -> { - final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state); - final Hint hint = new Hint(); - hint.set(ANDROID_INTENT, intent); - scopes.addBreadcrumb(breadcrumb, hint); - }); - } catch (Throwable t) { - // ignored - } + final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state); + final Hint hint = new Hint(); + hint.set(ANDROID_INTENT, intent); + scopes.addBreadcrumb(breadcrumb, hint); } // in theory this should be ThreadLocal, but we won't have more than 1 thread accessing it, // so we save some memory here and CPU cycles. 64 is because all intent actions we subscribe for // are less than 64 chars. We also don't care about encoding as those are always UTF. - // TODO: _MULTI_THREADED_EXECUTOR_ private final char[] buf = new char[64]; @TestOnly @@ -365,8 +372,8 @@ String getStringAfterDotFast(final @Nullable String str) { } } else { final Bundle extras = intent.getExtras(); - final Map newExtras = new HashMap<>(); if (extras != null && !extras.isEmpty()) { + final Map newExtras = new HashMap<>(extras.size()); for (String item : extras.keySet()) { try { @SuppressWarnings("deprecation") diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index 998a24b3d2c..f4b4da814b8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -80,6 +80,8 @@ class AndroidProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false + + override fun prewarm() = Unit } val options = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index 0490bc30d09..ec6ba18d65e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -89,6 +89,8 @@ class AndroidTransactionProfilerTest { override fun close(timeoutMillis: Long) {} override fun isClosed() = false + + override fun prewarm() = Unit } val options = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index b9bb6170e44..2e540ee13df 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -30,6 +30,7 @@ import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.verify @@ -221,8 +222,8 @@ class ContextUtilsTest { val filter = mock() val context = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S) - ContextUtils.registerReceiver(context, buildInfo, receiver, filter) - verify(context).registerReceiver(eq(receiver), eq(filter)) + ContextUtils.registerReceiver(context, buildInfo, receiver, filter, null) + verify(context).registerReceiver(eq(receiver), eq(filter), isNull(), isNull()) } @Test @@ -232,8 +233,15 @@ class ContextUtilsTest { val filter = mock() val context = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU) - ContextUtils.registerReceiver(context, buildInfo, receiver, filter) - verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_NOT_EXPORTED)) + ContextUtils.registerReceiver(context, buildInfo, receiver, filter, null) + verify(context) + .registerReceiver( + eq(receiver), + eq(filter), + isNull(), + isNull(), + eq(Context.RECEIVER_NOT_EXPORTED), + ) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 1c17ac7c65b..65eb3cb46f3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -89,7 +89,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - verify(fixture.context).registerReceiver(any(), any(), any()) + verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any()) assertNotNull(sut.receiver) } @@ -299,7 +299,8 @@ class SystemEventsBreadcrumbsIntegrationTest { @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() - whenever(fixture.context.registerReceiver(any(), any(), any())).thenThrow(SecurityException()) + whenever(fixture.context.registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any())) + .thenThrow(SecurityException()) sut.register(fixture.scopes, fixture.options) @@ -448,12 +449,13 @@ class SystemEventsBreadcrumbsIntegrationTest { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - verify(fixture.context).registerReceiver(any(), any(), any()) + verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any()) sut.onBackground() sut.onForeground() - verify(fixture.context, times(2)).registerReceiver(any(), any(), any()) + verify(fixture.context, times(2)) + .registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any()) assertNotNull(sut.receiver) } @@ -462,7 +464,7 @@ class SystemEventsBreadcrumbsIntegrationTest { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - verify(fixture.context).registerReceiver(any(), any(), any()) + verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any()) val receiver = sut.receiver sut.onForeground() diff --git a/sentry-test-support/api/sentry-test-support.api b/sentry-test-support/api/sentry-test-support.api index 2623a7813d2..ce9d0506ea6 100644 --- a/sentry-test-support/api/sentry-test-support.api +++ b/sentry-test-support/api/sentry-test-support.api @@ -20,6 +20,7 @@ public final class io/sentry/test/DeferredExecutorService : io/sentry/ISentryExe public fun close (J)V public final fun hasScheduledRunnables ()Z public fun isClosed ()Z + public fun prewarm ()V public final fun runAll ()V public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; @@ -30,6 +31,7 @@ public final class io/sentry/test/ImmediateExecutorService : io/sentry/ISentryEx public fun ()V public fun close (J)V public fun isClosed ()Z + public fun prewarm ()V public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future; 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 49189f183e3..09d5d181ec4 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 @@ -35,6 +35,8 @@ class ImmediateExecutorService : ISentryExecutorService { override fun close(timeoutMillis: Long) {} override fun isClosed(): Boolean = false + + override fun prewarm() = Unit } class DeferredExecutorService : ISentryExecutorService { @@ -72,6 +74,8 @@ class DeferredExecutorService : ISentryExecutorService { override fun isClosed(): Boolean = false + override fun prewarm() = Unit + fun hasScheduledRunnables(): Boolean = scheduledRunnables.isNotEmpty() } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0433872b299..8e3804d0115 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1030,6 +1030,7 @@ public abstract interface class io/sentry/ISentryClient { public abstract interface class io/sentry/ISentryExecutorService { public abstract fun close (J)V public abstract fun isClosed ()Z + public abstract fun prewarm ()V public abstract fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; public abstract fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; public abstract fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future; @@ -2957,8 +2958,10 @@ public final class io/sentry/SentryExceptionFactory { public final class io/sentry/SentryExecutorService : io/sentry/ISentryExecutorService { public fun ()V + public fun (Lio/sentry/SentryOptions;)V public fun close (J)V public fun isClosed ()Z + public fun prewarm ()V public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 6a5a2182a89..bad0ea56e50 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -53,6 +53,7 @@ tasks { dependsOn(jacocoTestReport) } test { + jvmArgs("--add-opens", "java.base/java.util.concurrent=ALL-UNNAMED") environment["SENTRY_TEST_PROPERTY"] = "\"some-value\"" environment["SENTRY_TEST_MAP_KEY1"] = "\"value1\"" environment["SENTRY_TEST_MAP_KEY2"] = "value2" diff --git a/sentry/src/main/java/io/sentry/ISentryExecutorService.java b/sentry/src/main/java/io/sentry/ISentryExecutorService.java index 9bdef8db2b7..ffad05361f6 100644 --- a/sentry/src/main/java/io/sentry/ISentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/ISentryExecutorService.java @@ -45,4 +45,10 @@ Future schedule(final @NotNull Runnable runnable, final long delayMillis) * @return If the executorService was previously closed */ boolean isClosed(); + + /** + * Pre-warms the executor service by increasing the initial queue capacity. SHOULD be called + * directly after instantiating this executor service. + */ + void prewarm(); } diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index d996fa29d59..dd1a202b548 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -5,6 +5,7 @@ import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.LazyEvaluator; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; @@ -20,7 +21,8 @@ public final class NoOpScope implements IScope { private static final NoOpScope instance = new NoOpScope(); - private final @NotNull SentryOptions emptyOptions = SentryOptions.empty(); + private final @NotNull LazyEvaluator emptyOptions = + new LazyEvaluator<>(() -> SentryOptions.empty()); private NoOpScope() {} @@ -229,7 +231,7 @@ public void withTransaction(Scope.@NotNull IWithTransaction callback) {} @ApiStatus.Internal @Override public @NotNull SentryOptions getOptions() { - return emptyOptions; + return emptyOptions.getValue(); } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index e1cc49a34a7..34adb15769b 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -7,6 +7,7 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.transport.RateLimiter; +import io.sentry.util.LazyEvaluator; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -16,7 +17,8 @@ public final class NoOpScopes implements IScopes { private static final NoOpScopes instance = new NoOpScopes(); - private final @NotNull SentryOptions emptyOptions = SentryOptions.empty(); + private final @NotNull LazyEvaluator emptyOptions = + new LazyEvaluator<>(() -> SentryOptions.empty()); private NoOpScopes() {} @@ -274,7 +276,7 @@ public void setActiveSpan(final @Nullable ISpan span) {} @Override public @NotNull SentryOptions getOptions() { - return emptyOptions; + return emptyOptions.getValue(); } @Override diff --git a/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java b/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java index 735c0dc29a3..d1dc6b207e5 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java @@ -36,4 +36,7 @@ public void close(long timeoutMillis) {} public boolean isClosed() { return false; } + + @Override + public void prewarm() {} } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f24ecab9e78..c462f91025b 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -343,7 +343,8 @@ private static void init(final @NotNull SentryOptions options, final boolean glo // to // set a new one if (options.getExecutorService().isClosed()) { - options.setExecutorService(new SentryExecutorService()); + options.setExecutorService(new SentryExecutorService(options)); + options.getExecutorService().prewarm(); } // when integrations are registered on Scopes ctor and async integrations are fired, // it might and actually happened that integrations called captureSomething diff --git a/sentry/src/main/java/io/sentry/SentryExecutorService.java b/sentry/src/main/java/io/sentry/SentryExecutorService.java index bb08e51b3a0..3fe262b5538 100644 --- a/sentry/src/main/java/io/sentry/SentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/SentryExecutorService.java @@ -2,43 +2,97 @@ import io.sentry.util.AutoClosableReentrantLock; import java.util.concurrent.Callable; -import java.util.concurrent.Executors; +import java.util.concurrent.CancellationException; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class SentryExecutorService implements ISentryExecutorService { - private final @NotNull ScheduledExecutorService executorService; + /** + * ScheduledThreadPoolExecutor grows work queue by 50% each time. With the initial capacity of 16 + * it will have to resize 4 times to reach 40, which is a decent middle-ground for prewarming. + * This will prevent from growing in unexpected areas of the SDK. + */ + private static final int INITIAL_QUEUE_SIZE = 40; + + /** + * By default, the work queue is unbounded so it can grow as much as the memory allows. We want to + * limit it by 271 which would be x8 times growth from the default initial capacity. + */ + private static final int MAX_QUEUE_SIZE = 271; + + private final @NotNull ScheduledThreadPoolExecutor executorService; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + @SuppressWarnings("UnnecessaryLambda") + private final @NotNull Runnable dummyRunnable = () -> {}; + + private final @Nullable SentryOptions options; + @TestOnly - SentryExecutorService(final @NotNull ScheduledExecutorService executorService) { + SentryExecutorService( + final @NotNull ScheduledThreadPoolExecutor executorService, + final @Nullable SentryOptions options) { this.executorService = executorService; + this.options = options; + } + + public SentryExecutorService(final @Nullable SentryOptions options) { + this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), options); } public SentryExecutorService() { - this(Executors.newSingleThreadScheduledExecutor(new SentryExecutorServiceThreadFactory())); + this(new ScheduledThreadPoolExecutor(1, new SentryExecutorServiceThreadFactory()), null); } @Override public @NotNull Future submit(final @NotNull Runnable runnable) { - return executorService.submit(runnable); + if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { + return executorService.submit(runnable); + } + // TODO: maybe RejectedExecutionException? + if (options != null) { + options + .getLogger() + .log(SentryLevel.WARNING, "Task " + runnable + " rejected from " + executorService); + } + return new CancelledFuture<>(); } @Override public @NotNull Future submit(final @NotNull Callable callable) { - return executorService.submit(callable); + if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { + return executorService.submit(callable); + } + // TODO: maybe RejectedExecutionException? + if (options != null) { + options + .getLogger() + .log(SentryLevel.WARNING, "Task " + callable + " rejected from " + executorService); + } + return new CancelledFuture<>(); } @Override public @NotNull Future schedule(final @NotNull Runnable runnable, final long delayMillis) { - return executorService.schedule(runnable, delayMillis, TimeUnit.MILLISECONDS); + if (executorService.getQueue().size() < MAX_QUEUE_SIZE) { + return executorService.schedule(runnable, delayMillis, TimeUnit.MILLISECONDS); + } + // TODO: maybe RejectedExecutionException? + if (options != null) { + options + .getLogger() + .log(SentryLevel.WARNING, "Task " + runnable + " rejected from " + executorService); + } + return new CancelledFuture<>(); } @Override @@ -65,6 +119,25 @@ public boolean isClosed() { } } + @SuppressWarnings({"FutureReturnValueIgnored"}) + @Override + public void prewarm() { + executorService.submit( + () -> { + try { + // schedule a bunch of dummy runnables in the future that will never execute to trigger + // queue growth and then purge the queue + for (int i = 0; i < INITIAL_QUEUE_SIZE; i++) { + final Future future = executorService.schedule(dummyRunnable, 365L, TimeUnit.DAYS); + future.cancel(true); + } + executorService.purge(); + } catch (RejectedExecutionException ignored) { + // ignore + } + }); + } + private static final class SentryExecutorServiceThreadFactory implements ThreadFactory { private int cnt; @@ -75,4 +148,31 @@ private static final class SentryExecutorServiceThreadFactory implements ThreadF return ret; } } + + private static final class CancelledFuture implements Future { + @Override + public boolean cancel(final boolean mayInterruptIfRunning) { + return true; + } + + @Override + public boolean isCancelled() { + return true; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public T get() { + throw new CancellationException(); + } + + @Override + public T get(final long timeout, final @NotNull TimeUnit unit) { + throw new CancellationException(); + } + } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 1ca089d7885..0a8227408ad 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3072,7 +3072,8 @@ private SentryOptions(final boolean empty) { setSpanFactory(SpanFactoryFactory.create(new LoadClass(), NoOpLogger.getInstance())); // SentryExecutorService should be initialized before any // SendCachedEventFireAndForgetIntegration - executorService = new SentryExecutorService(); + executorService = new SentryExecutorService(this); + executorService.prewarm(); // UncaughtExceptionHandlerIntegration should be inited before any other Integration. // if there's an error on the setup, we are able to capture it diff --git a/sentry/src/main/java/io/sentry/SpotlightIntegration.java b/sentry/src/main/java/io/sentry/SpotlightIntegration.java index 910259ad131..1eb9ed83ae0 100644 --- a/sentry/src/main/java/io/sentry/SpotlightIntegration.java +++ b/sentry/src/main/java/io/sentry/SpotlightIntegration.java @@ -32,7 +32,7 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { this.logger = options.getLogger(); if (options.getBeforeEnvelopeCallback() == null && options.isEnableSpotlight()) { - executorService = new SentryExecutorService(); + executorService = new SentryExecutorService(options); options.setBeforeEnvelopeCallback(this); logger.log(DEBUG, "SpotlightIntegration enabled."); addIntegrationToSdkVersion("Spotlight"); diff --git a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java index 5aa03a74118..3d760263f38 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java @@ -40,7 +40,7 @@ public LoggerBatchProcessor( this.options = options; this.client = client; this.queue = new ConcurrentLinkedQueue<>(); - this.executorService = new SentryExecutorService(); + this.executorService = new SentryExecutorService(options); } @Override diff --git a/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt b/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt index 18cc73986a4..19dd60469cc 100644 --- a/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt +++ b/sentry/src/test/java/io/sentry/SentryExecutorServiceTest.kt @@ -1,13 +1,20 @@ package io.sentry -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService +import io.sentry.test.getProperty +import java.util.concurrent.BlockingQueue +import java.util.concurrent.Callable +import java.util.concurrent.CancellationException +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import org.awaitility.kotlin.await import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -16,24 +23,24 @@ import org.mockito.kotlin.whenever class SentryExecutorServiceTest { @Test fun `SentryExecutorService forwards submit call to ExecutorService`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock { on { queue } doReturn LinkedBlockingQueue() } + val sentryExecutor = SentryExecutorService(executor, null) sentryExecutor.submit {} verify(executor).submit(any()) } @Test fun `SentryExecutorService forwards schedule call to ExecutorService`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock { on { queue } doReturn LinkedBlockingQueue() } + val sentryExecutor = SentryExecutorService(executor, null) sentryExecutor.schedule({}, 0L) verify(executor).schedule(any(), any(), any()) } @Test fun `SentryExecutorService forwards close call to ExecutorService`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(false) whenever(executor.awaitTermination(any(), any())).thenReturn(true) sentryExecutor.close(15000) @@ -42,8 +49,8 @@ class SentryExecutorServiceTest { @Test fun `SentryExecutorService forwards close and call shutdownNow if not enough time`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(false) whenever(executor.awaitTermination(any(), any())).thenReturn(false) sentryExecutor.close(15000) @@ -52,8 +59,8 @@ class SentryExecutorServiceTest { @Test fun `SentryExecutorService forwards close and call shutdownNow if await throws`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(false) whenever(executor.awaitTermination(any(), any())).thenThrow(InterruptedException()) sentryExecutor.close(15000) @@ -62,8 +69,8 @@ class SentryExecutorServiceTest { @Test fun `SentryExecutorService forwards close but do not shutdown if its already closed`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(true) sentryExecutor.close(15000) verify(executor, never()).shutdown() @@ -71,8 +78,8 @@ class SentryExecutorServiceTest { @Test fun `SentryExecutorService forwards close call to ExecutorService and close it`() { - val executor = Executors.newSingleThreadScheduledExecutor() - val sentryExecutor = SentryExecutorService(executor) + val executor = ScheduledThreadPoolExecutor(1) + val sentryExecutor = SentryExecutorService(executor, null) sentryExecutor.close(15000) assertTrue(executor.isShutdown) } @@ -88,17 +95,134 @@ class SentryExecutorServiceTest { @Test fun `SentryExecutorService isClosed returns true if executor is shutdown`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(true) assertTrue(sentryExecutor.isClosed) } @Test fun `SentryExecutorService isClosed returns false if executor is not shutdown`() { - val executor = mock() - val sentryExecutor = SentryExecutorService(executor) + val executor = mock() + val sentryExecutor = SentryExecutorService(executor, null) whenever(executor.isShutdown).thenReturn(false) assertFalse(sentryExecutor.isClosed) } + + @Test + fun `SentryExecutorService submit runnable returns cancelled future when queue size exceeds limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(272) // Above MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val options = mock() + val logger = mock() + whenever(options.logger).thenReturn(logger) + + val sentryExecutor = SentryExecutorService(executor, options) + val future = sentryExecutor.submit {} + + assertTrue(future.isCancelled) + assertTrue(future.isDone) + assertFailsWith { future.get() } + verify(executor, never()).submit(any()) + verify(logger).log(any(), any()) + } + + @Test + fun `SentryExecutorService submit runnable accepts when queue size is within limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(270) // Below MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val sentryExecutor = SentryExecutorService(executor, null) + sentryExecutor.submit {} + + verify(executor).submit(any()) + } + + @Test + fun `SentryExecutorService submit callable returns cancelled future when queue size exceeds limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(272) // Above MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val options = mock() + val logger = mock() + whenever(options.logger).thenReturn(logger) + + val sentryExecutor = SentryExecutorService(executor, options) + val future = sentryExecutor.submit(Callable { "result" }) + + assertTrue(future.isCancelled) + assertTrue(future.isDone) + assertFailsWith { future.get() } + verify(executor, never()).submit(any>()) + verify(logger).log(any(), any()) + } + + @Test + fun `SentryExecutorService submit callable accepts when queue size is within limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(270) // Below MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val sentryExecutor = SentryExecutorService(executor, null) + sentryExecutor.submit(Callable { "result" }) + + verify(executor).submit(any>()) + } + + @Test + fun `SentryExecutorService schedule returns cancelled future when queue size exceeds limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(272) // Above MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val options = mock() + val logger = mock() + whenever(options.logger).thenReturn(logger) + + val sentryExecutor = SentryExecutorService(executor, options) + val future = sentryExecutor.schedule({}, 1000L) + + assertTrue(future.isCancelled) + assertTrue(future.isDone) + assertFailsWith { future.get() } + verify(executor, never()).schedule(any(), any(), any()) + verify(logger).log(any(), any()) + } + + @Test + fun `SentryExecutorService schedule accepts when queue size is within limit`() { + val queue = mock>() + whenever(queue.size).thenReturn(270) // Below MAX_QUEUE_SIZE (271) + + val executor = mock { on { getQueue() } doReturn queue } + + val sentryExecutor = SentryExecutorService(executor, null) + sentryExecutor.schedule({}, 1000L) + + verify(executor).schedule(any(), any(), any()) + } + + @Test + fun `SentryExecutorService prewarm schedules dummy tasks and clears queue`() { + val executor = ScheduledThreadPoolExecutor(1) + + val sentryExecutor = SentryExecutorService(executor, null) + sentryExecutor.prewarm() + + Thread.sleep(1000) + + // the internal queue/array should be resized 4 times to 54 + assertEquals(54, (executor.queue.getProperty("queue") as Array<*>).size) + // the queue should be empty + assertEquals(0, executor.queue.size) + } } From 644dcd803ae96b4ca5d09bc708d13df0d6a2c1c7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 Aug 2025 11:16:59 +0200 Subject: [PATCH 18/21] review feedback --- .../api/sentry-android-core.api | 1 + .../core/cache/AndroidEnvelopeCache.java | 10 ++++ sentry/api/sentry.api | 1 + .../java/io/sentry/cache/EnvelopeCache.java | 18 +++++-- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 51 ++++++++++++++----- .../transport/AsyncHttpTransportTest.kt | 6 +-- 6 files changed, 69 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 6bcb961b321..f3adbd6ee8b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -483,6 +483,7 @@ public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } public class io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter : android/app/Application$ActivityLifecycleCallbacks { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 6e7163f36c6..c0799b54be3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -50,6 +50,15 @@ public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { @SuppressWarnings("deprecation") @Override public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + storeInternalAndroid(envelope, hint); + } + + @Override + public boolean storeEnvelope(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { + return storeInternalAndroid(envelope, hint); + } + + private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { super.store(envelope, hint); final SentryAndroidOptions options = (SentryAndroidOptions) this.options; @@ -84,6 +93,7 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { writeLastReportedAnrMarker(timestamp); }); + return true; } @TestOnly diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8e3804d0115..9d2a540e942 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4354,6 +4354,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static fun getPreviousSessionFile (Ljava/lang/String;)Ljava/io/File; public fun iterator ()Ljava/util/Iterator; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z public fun waitPreviousSessionFlush ()Z } diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 1ca4c6a1e0a..1aca185d9cc 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -96,6 +96,15 @@ public EnvelopeCache( @SuppressWarnings("deprecation") @Override public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { + storeInternal(envelope, hint); + } + + @Override + public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { + return storeInternal(envelope, hint); + } + + private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); rotateCacheIfNeeded(allEnvelopeFiles()); @@ -172,19 +181,20 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi WARNING, "Not adding Envelope to offline storage because it already exists: %s", envelopeFile.getAbsolutePath()); - return; + return true; } else { options .getLogger() .log(DEBUG, "Adding Envelope to offline storage: %s", envelopeFile.getAbsolutePath()); } - writeEnvelopeToDisk(envelopeFile, envelope); + final boolean didWriteToDisk = writeEnvelopeToDisk(envelopeFile, envelope); // write file to the disk when its about to crash so crashedLastRun can be marked on restart if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class)) { writeCrashMarkerFile(); } + return didWriteToDisk; } /** @@ -296,7 +306,7 @@ private void updateCurrentSession( } } - private void writeEnvelopeToDisk( + private boolean writeEnvelopeToDisk( final @NotNull File file, final @NotNull SentryEnvelope envelope) { if (file.exists()) { options @@ -313,7 +323,9 @@ private void writeEnvelopeToDisk( options .getLogger() .log(ERROR, e, "Error writing Envelope %s to offline storage", file.getAbsolutePath()); + return false; } + return true; } private void writeSessionToDisk(final @NotNull File file, final @NotNull Session session) { diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 2b34fac6ffc..f824c4fcf62 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -3,6 +3,7 @@ package io.sentry.cache import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger +import io.sentry.ISerializer import io.sentry.NoOpLogger import io.sentry.SentryCrashLastRunState import io.sentry.SentryEnvelope @@ -31,7 +32,10 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.whenever class EnvelopeCacheTest { private class Fixture { @@ -39,12 +43,14 @@ class EnvelopeCacheTest { val options = SentryOptions() val logger = mock() - fun getSUT(): EnvelopeCache { + fun getSUT(optionsCallback: ((SentryOptions) -> Unit)? = null): EnvelopeCache { options.cacheDirPath = dir.toAbsolutePath().toFile().absolutePath options.setLogger(logger) options.setDebug(true) + optionsCallback?.invoke(options) + return EnvelopeCache.create(options) as EnvelopeCache } } @@ -90,13 +96,15 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) val currentFile = File(fixture.options.cacheDirPath!!, "$PREFIX_CURRENT_SESSION_FILE$SUFFIX_SESSION_FILE") assertTrue(currentFile.exists()) file.deleteRecursively() + + assertTrue(didStore) } @Test @@ -108,7 +116,7 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) val currentFile = File(fixture.options.cacheDirPath!!, "$PREFIX_CURRENT_SESSION_FILE$SUFFIX_SESSION_FILE") @@ -119,6 +127,7 @@ class EnvelopeCacheTest { assertFalse(currentFile.exists()) file.deleteRecursively() + assertTrue(didStore) } @Test @@ -130,7 +139,7 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) val currentFile = File(fixture.options.cacheDirPath!!, "$PREFIX_CURRENT_SESSION_FILE$SUFFIX_SESSION_FILE") @@ -146,6 +155,7 @@ class EnvelopeCacheTest { currentFile.delete() file.deleteRecursively() + assertTrue(didStore) } @Test @@ -160,7 +170,7 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) val newEnvelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) @@ -172,6 +182,7 @@ class EnvelopeCacheTest { // passing empty string since readCrashedLastRun is already set assertTrue(SentryCrashLastRunState.getInstance().isCrashedLastRun("", false)!!) + assertTrue(didStore) } @Test @@ -185,11 +196,12 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, createSession(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) // passing empty string since readCrashedLastRun is already set assertTrue(SentryCrashLastRunState.getInstance().isCrashedLastRun("", false)!!) assertFalse(markerFile.exists()) + assertTrue(didStore) } @Test @@ -203,9 +215,10 @@ class EnvelopeCacheTest { val hints = HintUtils.createWithTypeCheckHint(UncaughtExceptionHint(0, NoOpLogger.getInstance())) - cache.store(envelope, hints) + val didStore = cache.storeEnvelope(envelope, hints) assertTrue(markerFile.exists()) + assertTrue(didStore) } @Test @@ -214,7 +227,7 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + cache.storeEnvelope(envelope, hints) assertTrue(cache.waitPreviousSessionFlush()) } @@ -232,7 +245,7 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) val hints = HintUtils.createWithTypeCheckHint(SessionStartHint()) - cache.store(envelope, hints) + cache.storeEnvelope(envelope, hints) assertTrue(previousSessionFile.exists()) val persistedSession = @@ -261,7 +274,7 @@ class EnvelopeCacheTest { override fun timestamp(): Long? = null } val hints = HintUtils.createWithTypeCheckHint(abnormalHint) - cache.store(envelope, hints) + cache.storeEnvelope(envelope, hints) val updatedSession = fixture.options.serializer.deserialize( @@ -293,7 +306,7 @@ class EnvelopeCacheTest { override fun timestamp(): Long = sessionExitedWithAbnormal } val hints = HintUtils.createWithTypeCheckHint(abnormalHint) - cache.store(envelope, hints) + cache.storeEnvelope(envelope, hints) val updatedSession = fixture.options.serializer.deserialize( @@ -323,7 +336,7 @@ class EnvelopeCacheTest { override fun timestamp(): Long = sessionExitedWithAbnormal } val hints = HintUtils.createWithTypeCheckHint(abnormalHint) - cache.store(envelope, hints) + cache.storeEnvelope(envelope, hints) val updatedSession = fixture.options.serializer.deserialize( @@ -334,6 +347,20 @@ class EnvelopeCacheTest { assertEquals(null, updatedSession.abnormalMechanism) } + @Test + fun `failing to store returns false`() { + val serializer = mock() + val envelope = SentryEnvelope.from(SentryOptions.empty().serializer, createSession(), null) + + whenever(serializer.serialize(same(envelope), any())).thenThrow(RuntimeException("forced ex")) + + val cache = fixture.getSUT { options -> options.setSerializer(serializer) } + + val didStore = cache.storeEnvelope(envelope, Hint()) + + assertFalse(didStore) + } + private fun createSession(started: Date? = null): Session = Session( Ok, diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index c3a7f2ce04f..27fc7dba0cb 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -85,7 +85,7 @@ class AsyncHttpTransportTest { val order = inOrder(fixture.connection, fixture.sentryOptions.envelopeDiskCache) // because storeBeforeSend is enabled by default - order.verify(fixture.sentryOptions.envelopeDiskCache).store(eq(envelope), anyOrNull()) + order.verify(fixture.sentryOptions.envelopeDiskCache).storeEnvelope(eq(envelope), anyOrNull()) order.verify(fixture.connection).send(eq(envelope)) order.verify(fixture.sentryOptions.envelopeDiskCache).discard(eq(envelope)) @@ -102,7 +102,7 @@ class AsyncHttpTransportTest { fixture.getSUT().send(envelope) // then - verify(fixture.sentryOptions.envelopeDiskCache).store(eq(envelope), anyOrNull()) + verify(fixture.sentryOptions.envelopeDiskCache).storeEnvelope(eq(envelope), anyOrNull()) verify(fixture.rateLimiter).filter(eq(envelope), anyOrNull()) } @@ -125,7 +125,7 @@ class AsyncHttpTransportTest { val order = inOrder(fixture.connection, fixture.sentryOptions.envelopeDiskCache) // because storeBeforeSend is enabled by default - order.verify(fixture.sentryOptions.envelopeDiskCache).store(eq(envelope), anyOrNull()) + order.verify(fixture.sentryOptions.envelopeDiskCache).storeEnvelope(eq(envelope), anyOrNull()) order.verify(fixture.connection).send(eq(envelope)) verify(fixture.sentryOptions.envelopeDiskCache, never()).discard(any()) From 78bb822b157073e192d1cdf14f25e12d249593b4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 Aug 2025 11:25:58 +0200 Subject: [PATCH 19/21] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec13dc20e51..31664263a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ ``` - Fix abstract method error in `SentrySupportSQLiteDatabase` ([#4597](https://github.com/getsentry/sentry-java/pull/4597)) - Ensure frame metrics listeners are registered/unregistered on the main thread ([#4582](https://github.com/getsentry/sentry-java/pull/4582)) +- Do not report cached events as lost ([#4575](https://github.com/getsentry/sentry-java/pull/4575)) + - Previously events were recorded as lost early despite being retried later through the cache ## 8.18.0 From 9ac34bbb3d920dab79b1baa4f11f822bc5cd36d9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 8 Aug 2025 13:36:55 +0200 Subject: [PATCH 20/21] pass through whether cache stored in AndroidEnvelopeCache + test --- .../core/cache/AndroidEnvelopeCache.java | 4 +-- .../core/cache/AndroidEnvelopeCacheTest.kt | 31 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index c0799b54be3..2829abc50c9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -59,7 +59,7 @@ public boolean storeEnvelope(@NotNull SentryEnvelope envelope, @NotNull Hint hin } private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { - super.store(envelope, hint); + final boolean didStore = super.storeEnvelope(envelope, hint); final SentryAndroidOptions options = (SentryAndroidOptions) this.options; final TimeSpan sdkInitTimeSpan = AppStartMetrics.getInstance().getSdkInitTimeSpan(); @@ -93,7 +93,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull writeLastReportedAnrMarker(timestamp); }); - return true; + return didStore; } @TestOnly diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index d4e0a421eaf..0c187d3b2ee 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -1,7 +1,9 @@ package io.sentry.android.core.cache +import io.sentry.ISerializer import io.sentry.NoOpLogger import io.sentry.SentryEnvelope +import io.sentry.SentryOptions import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.SentryAndroidOptions @@ -18,7 +20,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.Rule import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.same import org.mockito.kotlin.whenever class AndroidEnvelopeCacheTest { @@ -35,8 +39,10 @@ class AndroidEnvelopeCacheTest { dir: TemporaryFolder, appStartMillis: Long? = null, currentTimeMillis: Long? = null, + optionsCallback: ((SentryOptions) -> Unit)? = null ): AndroidEnvelopeCache { options.cacheDirPath = dir.newFolder("sentry-cache").absolutePath + optionsCallback?.invoke(options) val outboxDir = File(options.outboxPath!!) outboxDir.mkdirs() @@ -82,7 +88,7 @@ class AndroidEnvelopeCacheTest { val cache = fixture.getSut(tmpDir) val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertFalse(fixture.startupCrashMarkerFile.exists()) } @@ -92,7 +98,7 @@ class AndroidEnvelopeCacheTest { val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 5000L) val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertFalse(fixture.startupCrashMarkerFile.exists()) } @@ -104,7 +110,7 @@ class AndroidEnvelopeCacheTest { fixture.options.cacheDirPath = null val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertFalse(fixture.startupCrashMarkerFile.exists()) } @@ -114,7 +120,7 @@ class AndroidEnvelopeCacheTest { val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertTrue(fixture.startupCrashMarkerFile.exists()) } @@ -138,7 +144,7 @@ class AndroidEnvelopeCacheTest { HintUtils.createWithTypeCheckHint( AnrV2Hint(0, NoOpLogger.getInstance(), 12345678L, false, false) ) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertFalse(fixture.lastReportedAnrFile.exists()) } @@ -151,7 +157,7 @@ class AndroidEnvelopeCacheTest { HintUtils.createWithTypeCheckHint( AnrV2Hint(0, NoOpLogger.getInstance(), 12345678L, false, false) ) - cache.store(fixture.envelope, hints) + cache.storeEnvelope(fixture.envelope, hints) assertTrue(fixture.lastReportedAnrFile.exists()) assertEquals("12345678", fixture.lastReportedAnrFile.readText()) @@ -189,5 +195,18 @@ class AndroidEnvelopeCacheTest { assertEquals(87654321L, lastReportedAnr) } + @Test + fun `returns false if storing fails`() { + val serializer = mock() + val cache = fixture.getSut(tmpDir) { options -> + options.setSerializer(serializer) + } + whenever(serializer.serialize(same(fixture.envelope), any())).thenThrow(RuntimeException("forced ex")) + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) + + val didStore = cache.storeEnvelope(fixture.envelope, hints) + assertFalse(didStore) + } + internal class UncaughtHint : UncaughtExceptionHint(0, NoOpLogger.getInstance()) } From b4ac53c2dc244a8c597ffee7087598a4c2c4b267 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 8 Aug 2025 11:38:49 +0000 Subject: [PATCH 21/21] Format code --- .../android/core/cache/AndroidEnvelopeCacheTest.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 0c187d3b2ee..09d3a779df0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -39,7 +39,7 @@ class AndroidEnvelopeCacheTest { dir: TemporaryFolder, appStartMillis: Long? = null, currentTimeMillis: Long? = null, - optionsCallback: ((SentryOptions) -> Unit)? = null + optionsCallback: ((SentryOptions) -> Unit)? = null, ): AndroidEnvelopeCache { options.cacheDirPath = dir.newFolder("sentry-cache").absolutePath optionsCallback?.invoke(options) @@ -198,10 +198,9 @@ class AndroidEnvelopeCacheTest { @Test fun `returns false if storing fails`() { val serializer = mock() - val cache = fixture.getSut(tmpDir) { options -> - options.setSerializer(serializer) - } - whenever(serializer.serialize(same(fixture.envelope), any())).thenThrow(RuntimeException("forced ex")) + val cache = fixture.getSut(tmpDir) { options -> options.setSerializer(serializer) } + whenever(serializer.serialize(same(fixture.envelope), any())) + .thenThrow(RuntimeException("forced ex")) val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) val didStore = cache.storeEnvelope(fixture.envelope, hints)