diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1082fa9d2..c34859af468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ SentryAndroid.init( ``` +- Android: Flush logs when app enters background ([#4873](https://github.com/getsentry/sentry-java/pull/4873)) ### Improvements diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 1c89d8524c0..b7644fce94e 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 class io/sentry/android/core/AndroidLoggerApi : io/sentry/logger/LoggerApi, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable { + public fun (Lio/sentry/Scopes;)V + public fun close ()V + public fun onBackground ()V + public fun onForeground ()V +} + +public final class io/sentry/android/core/AndroidLoggerApiFactory : io/sentry/logger/ILoggerApiFactory { + public fun ()V + public fun create (Lio/sentry/Scopes;)Lio/sentry/logger/LoggerApi; +} + 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/AndroidLoggerApi.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerApi.java new file mode 100644 index 00000000000..57d1765352b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerApi.java @@ -0,0 +1,49 @@ +package io.sentry.android.core; + +import io.sentry.Scopes; +import io.sentry.SentryLevel; +import io.sentry.logger.LoggerApi; +import io.sentry.logger.LoggerBatchProcessor; +import java.io.Closeable; +import java.io.IOException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class AndroidLoggerApi extends LoggerApi implements Closeable, AppState.AppStateListener { + + public AndroidLoggerApi(final @NotNull Scopes scopes) { + super(scopes); + AppState.getInstance().addAppStateListener(this); + } + + @Override + @ApiStatus.Internal + public void close() throws IOException { + AppState.getInstance().removeAppStateListener(this); + } + + @Override + public void onForeground() {} + + @Override + public void onBackground() { + try { + scopes + .getOptions() + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + scopes.getClient().flushLogs(LoggerBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit log flush runnable"); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerApiFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerApiFactory.java new file mode 100644 index 00000000000..7d0518fc706 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLoggerApiFactory.java @@ -0,0 +1,17 @@ +package io.sentry.android.core; + +import io.sentry.Scopes; +import io.sentry.logger.ILoggerApiFactory; +import io.sentry.logger.LoggerApi; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AndroidLoggerApiFactory implements ILoggerApiFactory { + + @Override + @NotNull + public LoggerApi create(@NotNull Scopes scopes) { + return new AndroidLoggerApi(scopes); + } +} 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..64fdda06514 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 @@ -139,6 +139,8 @@ static void loadDefaultAndMetadataOptions( readDefaultOptionValues(options, finalContext, buildInfoProvider); AppState.getInstance().registerLifecycleObserver(options); + + options.setLoggerApiFactory(new AndroidLoggerApiFactory()); } @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 9fd90b23099..0246b9cd163 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 @@ -45,7 +45,8 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions this.options.isEnableAppLifecycleBreadcrumbs()); if (this.options.isEnableAutoSessionTracking() - || this.options.isEnableAppLifecycleBreadcrumbs()) { + || this.options.isEnableAppLifecycleBreadcrumbs() + || this.options.getLogs().isEnabled()) { try (final ISentryLifecycleToken ignored = lock.acquire()) { if (watcher != null) { return; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiFactoryTest.kt new file mode 100644 index 00000000000..a41f38f2fd5 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiFactoryTest.kt @@ -0,0 +1,19 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Scopes +import kotlin.test.assertIs +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@RunWith(AndroidJUnit4::class) +class AndroidLoggerApiFactoryTest { + + @Test + fun `factory creates AndroidLogger`() { + val factory = AndroidLoggerApiFactory() + val logger = factory.create(mock()) + assertIs(logger) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiTest.kt new file mode 100644 index 00000000000..6ecd124fa56 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidLoggerApiTest.kt @@ -0,0 +1,51 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Scopes +import io.sentry.SentryClient +import io.sentry.SentryOptions +import io.sentry.test.ImmediateExecutorService +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +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 AndroidLoggerApiTest { + + @Before + fun setup() { + AppState.getInstance().resetInstance() + } + + @Test + fun `AndroidLogger registers and unregisters app state listener`() { + val scopes = mock() + val logger = AndroidLoggerApi(scopes) + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isNotEmpty()) + + logger.close() + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + } + + @Test + fun `AndroidLogger triggers flushing if app goes in background`() { + val scopes = mock() + + val client = mock() + whenever(scopes.client).thenReturn(client) + + val options = SentryOptions() + options.executorService = ImmediateExecutorService() + whenever(scopes.options).thenReturn(options) + + val logger = AndroidLoggerApi(scopes) + logger.onBackground() + + verify(client).flushLogs(any()) + } +} 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..dfff8638cf3 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 @@ -926,4 +926,10 @@ class AndroidOptionsInitializerTest { fixture.initSut() assertIs(fixture.sentryOptions.runtimeManager) } + + @Test + fun `AndroidLoggerApiFactory is set in the options`() { + fixture.initSut() + assertIs(fixture.sentryOptions.loggerApiFactory) + } } 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 5149f167129..f40a543f05f 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 @@ -5,12 +5,14 @@ import io.sentry.DateUtils import io.sentry.IContinuousProfiler import io.sentry.IScope import io.sentry.IScopes +import io.sentry.ISentryClient import io.sentry.ReplayController import io.sentry.ScopeCallback import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.Session import io.sentry.Session.State +import io.sentry.test.ImmediateExecutorService import io.sentry.transport.ICurrentDateProvider import kotlin.test.BeforeTest import kotlin.test.Test @@ -36,6 +38,8 @@ class LifecycleWatcherTest { val replayController = mock() val continuousProfiler = mock() + val client = mock() + fun getSUT( sessionIntervalMillis: Long = 0L, enableAutoSessionTracking: Boolean = true, @@ -49,9 +53,13 @@ class LifecycleWatcherTest { whenever(scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + whenever(scope.client).thenReturn(client) + options.setReplayController(replayController) options.setContinuousProfiler(continuousProfiler) + options.executorService = ImmediateExecutorService() whenever(scopes.options).thenReturn(options) + whenever(scopes.globalScope).thenReturn(scope) return LifecycleWatcher( scopes, 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 bdb328e2421..e4bc271deb0 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 @@ -142,6 +142,10 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun flushLogs(timeoutMillis: Long) { + TODO("Not yet implemented") + } + override fun captureFeedback(feedback: Feedback, hint: Hint?, scope: IScope): SentryId { TODO("Not yet implemented") } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index bee8b9e343e..ea8148b93dc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1053,6 +1053,7 @@ public abstract interface class io/sentry/ISentryClient { public abstract fun close ()V public abstract fun close (Z)V public abstract fun flush (J)V + public abstract fun flushLogs (J)V public abstract fun getRateLimiter ()Lio/sentry/transport/RateLimiter; public abstract fun isEnabled ()Z public fun isHealthy ()Z @@ -2856,6 +2857,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient { public fun close ()V public fun close (Z)V public fun flush (J)V + public fun flushLogs (J)V public fun getRateLimiter ()Lio/sentry/transport/RateLimiter; public fun isEnabled ()Z public fun isHealthy ()Z @@ -3407,6 +3409,7 @@ public class io/sentry/SentryOptions { public fun getIntegrations ()Ljava/util/List; public fun getInternalTracesSampler ()Lio/sentry/TracesSampler; public fun getLogger ()Lio/sentry/ILogger; + public fun getLoggerApiFactory ()Lio/sentry/logger/ILoggerApiFactory; public fun getLogs ()Lio/sentry/SentryOptions$Logs; public fun getMaxAttachmentSize ()J public fun getMaxBreadcrumbs ()I @@ -3559,6 +3562,7 @@ public class io/sentry/SentryOptions { public fun setInitPriority (Lio/sentry/InitPriority;)V public fun setInstrumenter (Lio/sentry/Instrumenter;)V public fun setLogger (Lio/sentry/ILogger;)V + public fun setLoggerApiFactory (Lio/sentry/logger/ILoggerApiFactory;)V public fun setLogs (Lio/sentry/SentryOptions$Logs;)V public fun setMaxAttachmentSize (J)V public fun setMaxBreadcrumbs (I)V @@ -5021,6 +5025,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/DefaultLoggerApiFactory : io/sentry/logger/ILoggerApiFactory { + public fun ()V + public fun create (Lio/sentry/Scopes;)Lio/sentry/logger/LoggerApi; +} + 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 @@ -5033,13 +5042,18 @@ public abstract interface class io/sentry/logger/ILoggerApi { public abstract fun warn (Ljava/lang/String;[Ljava/lang/Object;)V } +public abstract interface class io/sentry/logger/ILoggerApiFactory { + public abstract fun create (Lio/sentry/Scopes;)Lio/sentry/logger/LoggerApi; +} + public abstract interface class io/sentry/logger/ILoggerBatchProcessor { public abstract fun add (Lio/sentry/SentryLogEvent;)V public abstract fun close (Z)V public abstract fun flush (J)V } -public final class io/sentry/logger/LoggerApi : io/sentry/logger/ILoggerApi { +public class io/sentry/logger/LoggerApi : io/sentry/logger/ILoggerApi { + protected final field scopes Lio/sentry/Scopes; public fun (Lio/sentry/Scopes;)V public fun debug (Ljava/lang/String;[Ljava/lang/Object;)V public fun error (Ljava/lang/String;[Ljava/lang/Object;)V diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index c2bc05516f4..09a3fe8724f 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -47,6 +47,8 @@ public interface ISentryClient { */ void flush(long timeoutMillis); + void flushLogs(long timeoutMillis); + /** * Captures the event. * diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 17e4becbc71..13163e004f2 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -38,6 +38,9 @@ public void close() {} @Override public void flush(long timeoutMillis) {} + @Override + public void flushLogs(long timeoutMillis) {} + @Override public @NotNull SentryId captureFeedback( @NotNull Feedback feedback, @Nullable Hint hint, @NotNull IScope scope) { diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index c8afde59cc1..e689f519892 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -4,7 +4,6 @@ import io.sentry.hints.SessionEndHint; import io.sentry.hints.SessionStartHint; import io.sentry.logger.ILoggerApi; -import io.sentry.logger.LoggerApi; import io.sentry.protocol.*; import io.sentry.transport.RateLimiter; import io.sentry.util.HintUtils; @@ -56,7 +55,7 @@ private Scopes( final @NotNull SentryOptions options = getOptions(); validateOptions(options); this.compositePerformanceCollector = options.getCompositePerformanceCollector(); - this.logger = new LoggerApi(this); + this.logger = options.getLoggerApiFactory().create(this); } public @NotNull String getCreator() { @@ -463,6 +462,10 @@ public void close(final boolean isRestarting) { executorService.close(getOptions().getShutdownTimeoutMillis()); } + if (logger instanceof Closeable) { + ((Closeable) logger).close(); + } + // TODO: should we end session before closing client? configureScope(ScopeType.CURRENT, scope -> scope.getClient().close(isRestarting)); configureScope(ScopeType.ISOLATION, scope -> scope.getClient().close(isRestarting)); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 73ff534dad8..a4e2600a2b3 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1563,10 +1563,15 @@ public void close(final boolean isRestarting) { @Override public void flush(final long timeoutMillis) { - loggerBatchProcessor.flush(timeoutMillis); + flushLogs(timeoutMillis); transport.flush(timeoutMillis); } + @Override + public void flushLogs(final long timeoutMillis) { + loggerBatchProcessor.flush(timeoutMillis); + } + @Override public @Nullable RateLimiter getRateLimiter() { return transport.getRateLimiter(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 368d9121959..d3b80291870 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.DefaultLoggerApiFactory; +import io.sentry.logger.ILoggerApiFactory; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; import io.sentry.transport.ITransport; @@ -626,6 +628,15 @@ public class SentryOptions { private @Nullable String profilingTracesDirPath; + private final @NotNull LazyEvaluator loggerApiFactory = + new LazyEvaluator<>( + new LazyEvaluator.Evaluator() { + @Override + public @NotNull ILoggerApiFactory evaluate() { + return new DefaultLoggerApiFactory(); + } + }); + public @NotNull IProfileConverter getProfilerConverter() { return profilerConverter; } @@ -3532,6 +3543,16 @@ public void setLogs(@NotNull SentryOptions.Logs logs) { this.logs = logs; } + @ApiStatus.Internal + public @NotNull ILoggerApiFactory getLoggerApiFactory() { + return loggerApiFactory.getValue(); + } + + @ApiStatus.Internal + public void setLoggerApiFactory(final @NotNull ILoggerApiFactory loggerApiFactory) { + this.loggerApiFactory.setValue(loggerApiFactory); + } + public static final class Proxy { private @Nullable String host; private @Nullable String port; diff --git a/sentry/src/main/java/io/sentry/logger/DefaultLoggerApiFactory.java b/sentry/src/main/java/io/sentry/logger/DefaultLoggerApiFactory.java new file mode 100644 index 00000000000..3cccc7e116e --- /dev/null +++ b/sentry/src/main/java/io/sentry/logger/DefaultLoggerApiFactory.java @@ -0,0 +1,15 @@ +package io.sentry.logger; + +import io.sentry.Scopes; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class DefaultLoggerApiFactory implements ILoggerApiFactory { + + @Override + @NotNull + public LoggerApi create(@NotNull final Scopes scopes) { + return new LoggerApi(scopes); + } +} diff --git a/sentry/src/main/java/io/sentry/logger/ILoggerApiFactory.java b/sentry/src/main/java/io/sentry/logger/ILoggerApiFactory.java new file mode 100644 index 00000000000..b2f88737faa --- /dev/null +++ b/sentry/src/main/java/io/sentry/logger/ILoggerApiFactory.java @@ -0,0 +1,11 @@ +package io.sentry.logger; + +import io.sentry.Scopes; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public interface ILoggerApiFactory { + @NotNull + LoggerApi create(@NotNull Scopes scopes); +} diff --git a/sentry/src/main/java/io/sentry/logger/LoggerApi.java b/sentry/src/main/java/io/sentry/logger/LoggerApi.java index 568bd6ac058..a5c964506fd 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerApi.java @@ -1,5 +1,6 @@ package io.sentry.logger; +import com.jakewharton.nopen.annotation.Open; import io.sentry.HostnameCache; import io.sentry.IScope; import io.sentry.ISpan; @@ -21,12 +22,15 @@ import io.sentry.util.Platform; import io.sentry.util.TracingUtils; import java.util.HashMap; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class LoggerApi implements ILoggerApi { +@ApiStatus.Internal +@Open +public class LoggerApi implements ILoggerApi { - private final @NotNull Scopes scopes; + protected final @NotNull Scopes scopes; public LoggerApi(final @NotNull Scopes scopes) { this.scopes = scopes; diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 4eee9d16367..0e1619a6dd5 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -7,6 +7,8 @@ import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent import io.sentry.hints.SessionEndHint import io.sentry.hints.SessionStartHint +import io.sentry.logger.ILoggerApiFactory +import io.sentry.logger.LoggerApi import io.sentry.logger.SentryLogParameters import io.sentry.protocol.Feedback import io.sentry.protocol.SentryId @@ -19,6 +21,7 @@ import io.sentry.test.createTestScopes import io.sentry.test.initForTest import io.sentry.util.HintUtils import io.sentry.util.StringUtils +import java.io.Closeable import java.io.File import java.nio.file.Files import java.util.Queue @@ -3212,6 +3215,17 @@ class ScopesTest { verify(mockClient).captureEvent(any(), check { assertNull(it.featureFlags) }, anyOrNull()) } + @Test + fun `scope close calls logger close`() { + val closeableLogger = mock(extraInterfaces = arrayOf(Closeable::class)) + val loggerFactory = ILoggerApiFactory { closeableLogger } + + val (scopes, _, _) = getEnabledScopes { options -> options.loggerApiFactory = loggerFactory } + scopes.close() + + verify(closeableLogger as Closeable).close() + } + private val dsnTest = "https://key@sentry.io/proj" private fun generateScopes(