From 87bc6be1545a0e8a29263bb5ebcbad621229276b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 14:47:47 +0200 Subject: [PATCH 1/2] perf(android): Start frame metrics thread lazily on first collection SentryFrameMetricsCollector created and started its HandlerThread in the constructor, blocking the calling thread (the main thread during SDK init) on HandlerThread.getLooper(). The handler is only needed once startCollection() registers a listener, so start the thread lazily there instead. Apps that never collect frame metrics no longer start the thread at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../util/SentryFrameMetricsCollector.java | 35 +++++++++++++++---- .../util/SentryFrameMetricsCollectorTest.kt | 10 ++++++ 2 files changed, 38 insertions(+), 7 deletions(-) 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 241ab1e4cca..4f76a51e86f 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 @@ -14,12 +14,14 @@ import android.view.Window; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -45,7 +47,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); private final @NotNull ILogger logger; - private @Nullable Handler handler; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -113,12 +116,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + // The frame metrics HandlerThread is started lazily on the first startCollection() call. + // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -281,12 +280,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } + ensureHandlerThreadStarted(); final String uid = SentryUUID.generateSentryId(); listenerMap.put(uid, listener); trackCurrentWindow(); return uid; } + /** + * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the + * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during + * SDK init) until the thread is ready, and the handler is only needed once collection starts. + */ + private void ensureHandlerThreadStarted() { + if (handler != null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { + if (handler == null) { + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; 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 02f65665a9e..f90c07b70e6 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 @@ -141,6 +141,16 @@ class SentryFrameMetricsCollectorTest { assertNotNull(id) } + @Test + fun `handler thread is started lazily on first startCollection`() { + val collector = fixture.getSut(context) + // not started during construction (would block the main thread on getLooper at SDK init) + assertNull(collector.getProperty("handler")) + + collector.startCollection(mock()) + assertNotNull(collector.getProperty("handler")) + } + @Test fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { val collector = fixture.getSut(context) From b557893d313ac2bf84ba9d85c84c86498aa7ac2a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 14:48:25 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a1115f8ae..dce5e8ec3b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Performance + +- Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) + ## 8.45.0 ### Features