diff --git a/CHANGELOG.md b/CHANGELOG.md index cce62395402..1e2fb45b128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Android: Flush logs when app enters background ([#4951](https://github.com/getsentry/sentry-java/pull/4951)) - Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919)) - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies - To enable, add url regexes via the `io.sentry.session-replay.network-detail-allow-urls` metadata tag in AndroidManifest ([code sample](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 1c89d8524c0..08895e6713f 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -82,6 +82,18 @@ public final class io/sentry/android/core/AndroidLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/android/core/AndroidLoggerBatchProcessor : io/sentry/logger/LoggerBatchProcessor, 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/AndroidLoggerBatchProcessorFactory : io/sentry/logger/ILoggerBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/logger/ILoggerBatchProcessor; +} + public class io/sentry/android/core/AndroidMemoryCollector : io/sentry/IPerformanceSnapshotCollector { public fun ()V public fun collect (Lio/sentry/PerformanceCollectionData;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java new file mode 100644 index 00000000000..13b12dc702a --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessor.java @@ -0,0 +1,47 @@ +package io.sentry.android.core; + +import io.sentry.ISentryClient; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.logger.LoggerBatchProcessor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AndroidLoggerBatchProcessor extends LoggerBatchProcessor + implements AppState.AppStateListener { + + public AndroidLoggerBatchProcessor( + @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(LoggerBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, t, "Failed to submit log 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/AndroidLoggerBatchProcessorFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactory.java new file mode 100644 index 00000000000..694f94c7f7b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactory.java @@ -0,0 +1,15 @@ +package io.sentry.android.core; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import io.sentry.logger.ILoggerBatchProcessor; +import io.sentry.logger.ILoggerBatchProcessorFactory; +import org.jetbrains.annotations.NotNull; + +public final class AndroidLoggerBatchProcessorFactory implements ILoggerBatchProcessorFactory { + @Override + public @NotNull ILoggerBatchProcessor create( + @NotNull SentryOptions options, @NotNull SentryClient client) { + return new AndroidLoggerBatchProcessor(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 4e679a22e96..c284d2256e2 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 @@ -123,6 +123,7 @@ static void loadDefaultAndMetadataOptions( options.setOpenTelemetryMode(SentryOpenTelemetryMode.OFF); options.setDateProvider(new SentryAndroidDateProvider()); options.setRuntimeManager(new AndroidRuntimeManager()); + options.getLogs().setLoggerBatchProcessorFactory(new AndroidLoggerBatchProcessorFactory()); // 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/AndroidLoggerBatchProcessorFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactoryTest.kt new file mode 100644 index 00000000000..33d66c2b9ad --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorFactoryTest.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 AndroidLoggerBatchProcessorFactoryTest { + + @Test + fun `create returns AndroidLoggerBatchProcessor instance`() { + val factory = AndroidLoggerBatchProcessorFactory() + 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/AndroidLoggerBatchProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt new file mode 100644 index 00000000000..ab83671fa0e --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerBatchProcessorTest.kt @@ -0,0 +1,97 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISentryClient +import io.sentry.SentryLogEvent +import io.sentry.SentryLogLevel +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 AndroidLoggerBatchProcessorTest { + + private class Fixture { + val options = SentryAndroidOptions() + val client: ISentryClient = mock() + + fun getSut( + useImmediateExecutor: Boolean = false, + config: ((SentryOptions) -> Unit)? = null, + ): AndroidLoggerBatchProcessor { + if (useImmediateExecutor) { + options.executorService = ImmediateExecutorService() + } + config?.invoke(options) + return AndroidLoggerBatchProcessor(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 logEvent = SentryLogEvent(SentryId(), 1.0, "test", SentryLogLevel.INFO) + sut.add(logEvent) + + sut.onBackground() + + verify(fixture.client).captureBatchedLogEvents(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()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 290ae6dea9d..47825cd1e7b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -774,6 +774,15 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.socketTagger is AndroidSocketTagger } } + @Test + fun `AndroidLoggerBatchProcessorFactory is set to options`() { + fixture.initSut() + + assertTrue { + fixture.sentryOptions.logs.loggerBatchProcessorFactory is AndroidLoggerBatchProcessorFactory + } + } + @Test fun `does not install ComposeGestureTargetLocator, if sentry-compose is not available`() { fixture.initSutWithClassLoader() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index bee8b9e343e..5cb6632011a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3669,9 +3669,11 @@ public final class io/sentry/SentryOptions$DistributionOptions { public final class io/sentry/SentryOptions$Logs { public fun ()V public fun getBeforeSend ()Lio/sentry/SentryOptions$Logs$BeforeSendLogCallback; + public fun getLoggerBatchProcessorFactory ()Lio/sentry/logger/ILoggerBatchProcessorFactory; public fun isEnabled ()Z public fun setBeforeSend (Lio/sentry/SentryOptions$Logs$BeforeSendLogCallback;)V public fun setEnabled (Z)V + public fun setLoggerBatchProcessorFactory (Lio/sentry/logger/ILoggerBatchProcessorFactory;)V } public abstract interface class io/sentry/SentryOptions$Logs$BeforeSendLogCallback { @@ -5021,6 +5023,11 @@ public abstract interface class io/sentry/internal/viewhierarchy/ViewHierarchyEx public abstract fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z } +public final class io/sentry/logger/DefaultLoggerBatchProcessorFactory : io/sentry/logger/ILoggerBatchProcessorFactory { + public fun ()V + public fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/logger/ILoggerBatchProcessor; +} + public abstract interface class io/sentry/logger/ILoggerApi { public abstract fun debug (Ljava/lang/String;[Ljava/lang/Object;)V public abstract fun error (Ljava/lang/String;[Ljava/lang/Object;)V @@ -5039,6 +5046,10 @@ public abstract interface class io/sentry/logger/ILoggerBatchProcessor { public abstract fun flush (J)V } +public abstract interface class io/sentry/logger/ILoggerBatchProcessorFactory { + public abstract fun create (Lio/sentry/SentryOptions;Lio/sentry/SentryClient;)Lio/sentry/logger/ILoggerBatchProcessor; +} + public final class io/sentry/logger/LoggerApi : io/sentry/logger/ILoggerApi { public fun (Lio/sentry/Scopes;)V public fun debug (Ljava/lang/String;[Ljava/lang/Object;)V @@ -5052,10 +5063,11 @@ public final class io/sentry/logger/LoggerApi : io/sentry/logger/ILoggerApi { public fun warn (Ljava/lang/String;[Ljava/lang/Object;)V } -public final class io/sentry/logger/LoggerBatchProcessor : io/sentry/logger/ILoggerBatchProcessor { +public class io/sentry/logger/LoggerBatchProcessor : io/sentry/logger/ILoggerBatchProcessor { public static final field FLUSH_AFTER_MS I public static final field MAX_BATCH_SIZE I public static final field MAX_QUEUE_SIZE I + protected final field options Lio/sentry/SentryOptions; public fun (Lio/sentry/SentryOptions;Lio/sentry/ISentryClient;)V public fun add (Lio/sentry/SentryLogEvent;)V public fun close (Z)V diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 73ff534dad8..0a767ed1b8e 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -9,7 +9,6 @@ import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; -import io.sentry.logger.LoggerBatchProcessor; import io.sentry.logger.NoOpLoggerBatchProcessor; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugMeta; @@ -62,7 +61,8 @@ public SentryClient(final @NotNull SentryOptions options) { final RequestDetailsResolver requestDetailsResolver = new RequestDetailsResolver(options); transport = transportFactory.create(options, requestDetailsResolver.resolve()); if (options.getLogs().isEnabled()) { - loggerBatchProcessor = new LoggerBatchProcessor(options, this); + loggerBatchProcessor = + options.getLogs().getLoggerBatchProcessorFactory().create(options, this); } else { loggerBatchProcessor = NoOpLoggerBatchProcessor.getInstance(); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 368d9121959..7cfdf24a722 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -15,6 +15,8 @@ import io.sentry.internal.modules.IModulesLoader; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.logger.DefaultLoggerBatchProcessorFactory; +import io.sentry.logger.ILoggerBatchProcessorFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; import io.sentry.transport.ITransport; @@ -3672,6 +3674,9 @@ public static final class Logs { */ private @Nullable BeforeSendLogCallback beforeSend; + private @NotNull ILoggerBatchProcessorFactory loggerBatchProcessorFactory = + new DefaultLoggerBatchProcessorFactory(); + /** * Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. * @@ -3708,6 +3713,17 @@ public void setBeforeSend(@Nullable BeforeSendLogCallback beforeSendLog) { this.beforeSend = beforeSendLog; } + @ApiStatus.Internal + public @NotNull ILoggerBatchProcessorFactory getLoggerBatchProcessorFactory() { + return loggerBatchProcessorFactory; + } + + @ApiStatus.Internal + public void setLoggerBatchProcessorFactory( + final @NotNull ILoggerBatchProcessorFactory loggerBatchProcessorFactory) { + this.loggerBatchProcessorFactory = loggerBatchProcessorFactory; + } + /** The BeforeSendLog callback */ public interface BeforeSendLogCallback { diff --git a/sentry/src/main/java/io/sentry/logger/DefaultLoggerBatchProcessorFactory.java b/sentry/src/main/java/io/sentry/logger/DefaultLoggerBatchProcessorFactory.java new file mode 100644 index 00000000000..c722da9be9d --- /dev/null +++ b/sentry/src/main/java/io/sentry/logger/DefaultLoggerBatchProcessorFactory.java @@ -0,0 +1,13 @@ +package io.sentry.logger; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; + +public final class DefaultLoggerBatchProcessorFactory implements ILoggerBatchProcessorFactory { + @Override + public @NotNull ILoggerBatchProcessor create( + @NotNull SentryOptions options, @NotNull SentryClient client) { + return new LoggerBatchProcessor(options, client); + } +} diff --git a/sentry/src/main/java/io/sentry/logger/ILoggerBatchProcessorFactory.java b/sentry/src/main/java/io/sentry/logger/ILoggerBatchProcessorFactory.java new file mode 100644 index 00000000000..029c3e15ef9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/logger/ILoggerBatchProcessorFactory.java @@ -0,0 +1,12 @@ +package io.sentry.logger; + +import io.sentry.SentryClient; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; + +public interface ILoggerBatchProcessorFactory { + + @NotNull + ILoggerBatchProcessor create( + final @NotNull SentryOptions options, final @NotNull SentryClient client); +} diff --git a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java index 48a73400f51..cdea169b925 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java @@ -1,5 +1,6 @@ package io.sentry.logger; +import com.jakewharton.nopen.annotation.Open; import io.sentry.DataCategory; import io.sentry.ISentryClient; import io.sentry.ISentryExecutorService; @@ -23,13 +24,14 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class LoggerBatchProcessor implements ILoggerBatchProcessor { +@Open +public class LoggerBatchProcessor implements ILoggerBatchProcessor { public static final int FLUSH_AFTER_MS = 5000; public static final int MAX_BATCH_SIZE = 100; public static final int MAX_QUEUE_SIZE = 1000; - private final @NotNull SentryOptions options; + protected final @NotNull SentryOptions options; private final @NotNull ISentryClient client; private final @NotNull Queue queue; private final @NotNull ISentryExecutorService executorService; diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index bf54db9228d..e882f6fdc6f 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1,6 +1,7 @@ package io.sentry import io.sentry.SentryOptions.RequestSize +import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.util.StringUtils import java.io.File import java.net.Proxy @@ -12,6 +13,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -908,4 +910,12 @@ class SentryOptionsTest { options.maxFeatureFlags = 50 assertEquals(50, options.maxFeatureFlags) } + + @Test + fun `loggerBatchFactory can be changed`() { + val mock = mock() + val options = SentryOptions() + options.logs.loggerBatchProcessorFactory = mock + assertSame(mock, options.logs.loggerBatchProcessorFactory) + } }