diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index eda1eef206..e094691b69 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -100,6 +100,18 @@ public class io/sentry/android/core/AndroidMemoryCollector : io/sentry/IPerforma public fun setup ()V } +public final class io/sentry/android/core/AndroidMetricsBatchProcessor : io/sentry/metrics/MetricsBatchProcessor, io/sentry/android/core/AppState$AppStateListener { + public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V + public fun close (Z)V + public fun onBackground ()V + public fun onForeground ()V +} + +public final class io/sentry/android/core/AndroidMetricsBatchProcessorFactory : io/sentry/metrics/IMetricsBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/metrics/IMetricsBatchProcessor; +} + public class io/sentry/android/core/AndroidProfiler { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ISentryExecutorService;Lio/sentry/ILogger;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java new file mode 100644 index 0000000000..64c612a886 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessor.java @@ -0,0 +1,49 @@ +package io.sentry.android.core; + +import io.sentry.ISentryClient; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.metrics.MetricsBatchProcessor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AndroidMetricsBatchProcessor extends MetricsBatchProcessor + implements AppState.AppStateListener { + + public AndroidMetricsBatchProcessor( + @NotNull SentryOptions options, @NotNull ISentryClient client) { + super(options, client); + AppState.getInstance().addAppStateListener(this); + } + + @Override + public void onForeground() { + // no-op + } + + @Override + public void onBackground() { + try { + options + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + flush(MetricsBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit metrics flush in onBackground()"); + } + } + + @Override + public void close(boolean isRestarting) { + AppState.getInstance().removeAppStateListener(this); + super.close(isRestarting); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java new file mode 100644 index 0000000000..eef0a6311f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactory.java @@ -0,0 +1,15 @@ +package io.sentry.android.core; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import io.sentry.metrics.IMetricsBatchProcessor; +import io.sentry.metrics.IMetricsBatchProcessorFactory; +import org.jetbrains.annotations.NotNull; + +public final class AndroidMetricsBatchProcessorFactory implements IMetricsBatchProcessorFactory { + @Override + public @NotNull IMetricsBatchProcessor create( + @NotNull SentryOptions options, @NotNull SentryClient client) { + return new AndroidMetricsBatchProcessor(options, client); + } +} 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 c284d2256e..6d9ecec605 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 @@ -124,6 +124,7 @@ static void loadDefaultAndMetadataOptions( options.setDateProvider(new SentryAndroidDateProvider()); options.setRuntimeManager(new AndroidRuntimeManager()); options.getLogs().setLoggerBatchProcessorFactory(new AndroidLoggerBatchProcessorFactory()); + options.getMetrics().setMetricsBatchProcessorFactory(new AndroidMetricsBatchProcessorFactory()); // set a lower flush timeout on Android to avoid ANRs options.setFlushTimeoutMillis(DEFAULT_FLUSH_TIMEOUT_MS); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt new file mode 100644 index 0000000000..9ddaa36abf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorFactoryTest.kt @@ -0,0 +1,23 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryClient +import kotlin.test.Test +import kotlin.test.assertIs +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@RunWith(AndroidJUnit4::class) +class AndroidMetricsBatchProcessorFactoryTest { + + @Test + fun `create returns AndroidMetricsBatchProcessor instance`() { + val factory = AndroidMetricsBatchProcessorFactory() + val options = SentryAndroidOptions() + val client: SentryClient = mock() + + val processor = factory.create(options, client) + + assertIs(processor) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt new file mode 100644 index 0000000000..fceb9ed3f4 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidMetricsBatchProcessorTest.kt @@ -0,0 +1,96 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISentryClient +import io.sentry.SentryMetricsEvent +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class AndroidMetricsBatchProcessorTest { + + private class Fixture { + val options = SentryAndroidOptions() + val client: ISentryClient = mock() + + fun getSut( + useImmediateExecutor: Boolean = false, + config: ((SentryOptions) -> Unit)? = null, + ): AndroidMetricsBatchProcessor { + if (useImmediateExecutor) { + options.executorService = ImmediateExecutorService() + } + config?.invoke(options) + return AndroidMetricsBatchProcessor(options, client) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + } + + @AfterTest + fun `tear down`() { + AppState.getInstance().resetInstance() + } + + @Test + fun `constructor registers as AppState listener`() { + fixture.getSut() + assertNotNull(AppState.getInstance().lifecycleObserver) + } + + @Test + fun `onBackground schedules flush`() { + val sut = fixture.getSut(useImmediateExecutor = true) + val metricsEvent = SentryMetricsEvent(SentryId(), 1.0, "test", "counter", 3.0) + sut.add(metricsEvent) + + sut.onBackground() + + verify(fixture.client).captureBatchedMetricsEvents(any()) + } + + @Test + fun `onBackground handles executor exception gracefully`() { + val sut = + fixture.getSut { options -> + val rejectingExecutor = mock() + whenever(rejectingExecutor.submit(any())).thenThrow(RuntimeException("Rejected")) + options.executorService = rejectingExecutor + } + + // Should not throw + sut.onBackground() + } + + @Test + fun `close removes AppState listener`() { + val sut = fixture.getSut() + sut.close(false) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } + + @Test + fun `close with isRestarting true still removes listener`() { + val sut = fixture.getSut() + sut.close(true) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } +}