From 415bd8296068b925b1230da67b8d18682c43efa0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 6 Nov 2025 10:44:04 +0100 Subject: [PATCH 1/3] feat(android): Add log flushing on app backgrounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add flushLogs() method to ISentryClient interface and implement it in SentryClient to allow log processors to flush any buffered logs when the app transitions to the background. This is integrated into the lifecycle watcher and can be enabled via an opt-in SentryOptions flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../android/core/AppLifecycleIntegration.java | 6 ++-- .../sentry/android/core/LifecycleWatcher.java | 32 +++++++++++++++++- .../core/AppLifecycleIntegrationTest.kt | 3 +- .../android/core/LifecycleWatcherTest.kt | 33 +++++++++++++++++++ .../core/SessionTrackingIntegrationTest.kt | 4 +++ .../src/main/AndroidManifest.xml | 10 +++--- .../sentry/samples/android/MainActivity.java | 20 +++++++++++ sentry/api/sentry.api | 2 ++ .../main/java/io/sentry/ISentryClient.java | 2 ++ .../main/java/io/sentry/NoOpSentryClient.java | 3 ++ .../src/main/java/io/sentry/SentryClient.java | 7 +++- 11 files changed, 111 insertions(+), 11 deletions(-) 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..b99c03fc072 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; @@ -56,7 +57,8 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions scopes, this.options.getSessionTrackingIntervalMillis(), this.options.isEnableAutoSessionTracking(), - this.options.isEnableAppLifecycleBreadcrumbs()); + this.options.isEnableAppLifecycleBreadcrumbs(), + this.options.getLogs().isEnabled()); AppState.getInstance().addAppStateListener(watcher); } 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 3d4cedb1b53..a59dc7783d3 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 @@ -5,6 +5,7 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.Session; +import io.sentry.logger.LoggerBatchProcessor; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; @@ -29,18 +30,22 @@ final class LifecycleWatcher implements AppState.AppStateListener { private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; + private final boolean enableLogFlushing; + private final @NotNull ICurrentDateProvider currentDateProvider; LifecycleWatcher( final @NotNull IScopes scopes, final long sessionIntervalMillis, final boolean enableSessionTracking, - final boolean enableAppLifecycleBreadcrumbs) { + final boolean enableAppLifecycleBreadcrumbs, + final boolean enableLogFlushing) { this( scopes, sessionIntervalMillis, enableSessionTracking, enableAppLifecycleBreadcrumbs, + enableLogFlushing, CurrentDateProvider.getInstance()); } @@ -49,10 +54,12 @@ final class LifecycleWatcher implements AppState.AppStateListener { final long sessionIntervalMillis, final boolean enableSessionTracking, final boolean enableAppLifecycleBreadcrumbs, + final boolean enableLogFlushing, final @NotNull ICurrentDateProvider currentDateProvider) { this.sessionIntervalMillis = sessionIntervalMillis; this.enableSessionTracking = enableSessionTracking; this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; + this.enableLogFlushing = enableLogFlushing; this.scopes = scopes; this.currentDateProvider = currentDateProvider; } @@ -101,6 +108,29 @@ public void onBackground() { scheduleEndSession(); addAppBreadcrumb("background"); + + if (enableLogFlushing) { + try { + scopes + .getOptions() + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + scopes + .getGlobalScope() + .getClient() + .flushLogs(LoggerBatchProcessor.FLUSH_AFTER_MS); + } + }); + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit log flush runnable"); + } + } } private void scheduleEndSession() { 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 896673085c2..323d60bbf90 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 @@ -34,11 +34,12 @@ class AppLifecycleIntegrationTest { } @Test - fun `When SessionTracking and AppLifecycle breadcrumbs are disabled, lifecycle watcher should not be started`() { + fun `When SessionTracking and AppLifecycle breadcrumbs and Logs are disabled, lifecycle watcher should not be started`() { val sut = fixture.getSut() fixture.options.apply { isEnableAppLifecycleBreadcrumbs = false isEnableAutoSessionTracking = false + logs.isEnabled = false } sut.register(fixture.scopes, fixture.options) 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..72d7bf853e7 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,10 +38,13 @@ class LifecycleWatcherTest { val replayController = mock() val continuousProfiler = mock() + val client = mock() + fun getSUT( sessionIntervalMillis: Long = 0L, enableAutoSessionTracking: Boolean = true, enableAppLifecycleBreadcrumbs: Boolean = true, + enableLogFlushing: Boolean = true, session: Session? = null, ): LifecycleWatcher { val argumentCaptor: ArgumentCaptor = @@ -49,15 +54,20 @@ 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, sessionIntervalMillis, enableAutoSessionTracking, enableAppLifecycleBreadcrumbs, + enableLogFlushing, dateProvider, ) } @@ -295,4 +305,27 @@ class LifecycleWatcherTest { watcher.onBackground() verify(fixture.replayController, timeout(10000)).stop() } + + @Test + fun `flush logs when going in background`() { + val watcher = fixture.getSUT(enableLogFlushing = true) + + watcher.onForeground() + watcher.onBackground() + + watcher.onForeground() + watcher.onBackground() + + verify(fixture.client, times(2)).flushLogs(any()) + } + + @Test + fun `do not flush logs when going in background when logging is disabled`() { + val watcher = fixture.getSUT(enableLogFlushing = false) + + watcher.onForeground() + watcher.onBackground() + + verify(fixture.client, never()).flushLogs(any()) + } } 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-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index afc6db9029f..9b119f0875f 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -84,7 +84,7 @@ android:exported="false" /> - + @@ -133,11 +133,11 @@ - + - + @@ -184,10 +184,8 @@ - - - + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 68372c7ab11..c59ee87096e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -374,6 +374,26 @@ public void run() { Sentry.logger().log(SentryLogLevel.INFO, "MainActivity created"); } + @Override + protected void onStart() { + super.onStart(); + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.startChild("data.load", "MainActivity: 001"); + new Thread( + () -> { + try { + Thread.sleep(25000L); + } catch (Exception e) { + // ignored + } + // span.finish(); + }, + "data.load") + .start(); + } + } + private void stackOverflow() { stackOverflow(); } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2211f053912..0d2f4d03cf3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1041,6 +1041,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 @@ -2824,6 +2825,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 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/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index bfcf4e780be..97f27ef0425 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1542,10 +1542,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(); From 5f14ca011b92cbe13c5d3bacda09b1c9f752304a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 6 Nov 2025 10:49:34 +0100 Subject: [PATCH 2/3] docs: update changelog for log flushing feature --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c55dcc5d73..880a09c53ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fallback to distinct-id as user.id logging attribute when user is not set ([#4847](https://github.com/getsentry/sentry-java/pull/4847)) - Report Timber.tag() as `timber.tag` log attribute ([#4845](https://github.com/getsentry/sentry-java/pull/4845)) - Session Replay: Add screenshot strategy serialization to RRWeb events ([#4851](https://github.com/getsentry/sentry-java/pull/4851)) +- Android: Flush log when app enters background ([#4873](https://github.com/getsentry/sentry-java/pull/4873)) ### Dependencies From 04d209cf9067af41fef867b08f94c16fd44993cb Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 6 Nov 2025 10:52:52 +0100 Subject: [PATCH 3/3] Revert local test changes --- .../src/main/AndroidManifest.xml | 274 +++++++++--------- .../sentry/samples/android/MainActivity.java | 20 -- 2 files changed, 137 insertions(+), 157 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9b119f0875f..92bdb038e36 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -1,191 +1,191 @@ + xmlns:tools="http://schemas.android.com/tools"> - - + + - + - - - - + + + + - + - - - - - - - - - - - - - - - - - - - - - - + android:name=".MyApplication" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network" + tools:ignore="GoogleAppIndexingWarning, UnusedAttribute"> + + + android:name=".MainActivity" + android:exported="true"> + + - + + + - + + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - - - - - + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index c59ee87096e..68372c7ab11 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -374,26 +374,6 @@ public void run() { Sentry.logger().log(SentryLogLevel.INFO, "MainActivity created"); } - @Override - protected void onStart() { - super.onStart(); - final ISpan span = Sentry.getSpan(); - if (span != null) { - span.startChild("data.load", "MainActivity: 001"); - new Thread( - () -> { - try { - Thread.sleep(25000L); - } catch (Exception e) { - // ignored - } - // span.finish(); - }, - "data.load") - .start(); - } - } - private void stackOverflow() { stackOverflow(); }