From 5a75171bdf9e21615488c22e725d0c985a893640 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Apr 2026 11:07:31 +0200 Subject: [PATCH 1/7] perf(android): Replace RNSentryFrameDelayCollector with sentry-java getFramesDelay API Uses the new queryable `SentryFrameMetricsCollector.getFramesDelay()` API from sentry-java (getsentry/sentry-java#5248, commit 61659b6) instead of maintaining a custom listener-based collector. Closes #5908 Co-Authored-By: Claude Opus 4.6 --- .../react/RNSentryFrameDelayCollector.java | 128 ------------------ .../io/sentry/react/RNSentryModuleImpl.java | 42 ++++-- 2 files changed, 34 insertions(+), 136 deletions(-) delete mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java deleted file mode 100644 index a3295ed4b4..0000000000 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java +++ /dev/null @@ -1,128 +0,0 @@ -package io.sentry.react; - -import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import org.jetbrains.annotations.Nullable; - -/** - * Collects per-frame delay data from {@link SentryFrameMetricsCollector} and provides a method to - * query the accumulated delay within a given time range. - * - *

This is a temporary solution until sentry-java exposes a queryable API for frames delay - * (similar to sentry-cocoa's getFramesDelaySPI). - */ -public class RNSentryFrameDelayCollector - implements SentryFrameMetricsCollector.FrameMetricsCollectorListener { - - private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes - - private final List frames = new CopyOnWriteArrayList<>(); - - private @Nullable String listenerId; - private @Nullable SentryFrameMetricsCollector collector; - - /** - * Starts collecting frame delay data from the given collector. - * - * @return true if collection was started successfully - */ - public boolean start(@Nullable SentryFrameMetricsCollector frameMetricsCollector) { - if (frameMetricsCollector == null) { - return false; - } - stop(); - this.collector = frameMetricsCollector; - this.listenerId = frameMetricsCollector.startCollection(this); - return this.listenerId != null; - } - - /** Stops collecting frame delay data. */ - public void stop() { - if (collector != null && listenerId != null) { - collector.stopCollection(listenerId); - listenerId = null; - collector = null; - } - frames.clear(); - } - - @Override - public void onFrameMetricCollected( - long frameStartNanos, - long frameEndNanos, - long durationNanos, - long delayNanos, - boolean isSlow, - boolean isFrozen, - float refreshRate) { - if (delayNanos <= 0) { - return; - } - frames.add(new FrameRecord(frameStartNanos, frameEndNanos, delayNanos)); - pruneOldFrames(frameEndNanos); - } - - /** - * Returns the total frames delay in seconds for the given time range. - * - *

Handles partial overlap: if a frame's delay period partially falls within the query range, - * only the overlapping portion is counted. - * - * @param startNanos start of the query range in system nanos (e.g., System.nanoTime()) - * @param endNanos end of the query range in system nanos - * @return delay in seconds, or -1 if no data is available - */ - public double getFramesDelay(long startNanos, long endNanos) { - if (startNanos >= endNanos) { - return -1; - } - - long totalDelayNanos = 0; - - for (FrameRecord frame : frames) { - if (frame.endNanos <= startNanos) { - continue; - } - if (frame.startNanos >= endNanos) { - break; - } - - // The delay portion of a frame is at the end of the frame duration. - // delayStart = frameEnd - delay, delayEnd = frameEnd - long delayStart = frame.endNanos - frame.delayNanos; - long delayEnd = frame.endNanos; - - // Intersect the delay interval with the query range - long overlapStart = Math.max(delayStart, startNanos); - long overlapEnd = Math.min(delayEnd, endNanos); - - if (overlapEnd > overlapStart) { - totalDelayNanos += (overlapEnd - overlapStart); - } - } - - return totalDelayNanos / 1e9; - } - - private void pruneOldFrames(long currentNanos) { - long cutoff = currentNanos - MAX_FRAME_AGE_NANOS; - // Remove from the front one-by-one. CopyOnWriteArrayList.remove(0) is O(n) per call, - // but old frames are pruned incrementally so typically only 0-1 entries are removed. - while (!frames.isEmpty() && frames.get(0).endNanos < cutoff) { - frames.remove(0); - } - } - - private static class FrameRecord { - final long startNanos; - final long endNanos; - final long delayNanos; - - FrameRecord(long startNanos, long endNanos, long delayNanos) { - this.startNanos = startNanos; - this.endNanos = endNanos; - this.delayNanos = delayNanos; - } - } -} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 4136eb5d3b..c255439416 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -48,6 +48,7 @@ import io.sentry.android.core.SentryShakeDetector; import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; +import io.sentry.android.core.SentryFramesDelayResult; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.Geo; @@ -98,7 +99,8 @@ public class RNSentryModuleImpl { private final ReactApplicationContext reactApplicationContext; private final PackageInfo packageInfo; private FrameMetricsAggregator frameMetricsAggregator = null; - private final RNSentryFrameDelayCollector frameDelayCollector = new RNSentryFrameDelayCollector(); + private @Nullable SentryFrameMetricsCollector frameMetricsCollector = null; + private @Nullable String frameMetricsListenerId = null; private boolean androidXAvailable; @VisibleForTesting static long lastStartTimestampMs = -1; @@ -413,9 +415,15 @@ public void fetchNativeFramesDelay( long startNanos = nowNanos - (long) (startOffsetSeconds * 1e9); long endNanos = nowNanos - (long) (endOffsetSeconds * 1e9); - double delaySeconds = frameDelayCollector.getFramesDelay(startNanos, endNanos); - if (delaySeconds >= 0) { - promise.resolve(delaySeconds); + if (frameMetricsCollector == null) { + promise.resolve(null); + return; + } + + SentryFramesDelayResult result = + frameMetricsCollector.getFramesDelay(startNanos, endNanos); + if (result.getDelaySeconds() >= 0) { + promise.resolve(result.getDelaySeconds()); } else { promise.resolve(null); } @@ -747,12 +755,22 @@ public void enableNativeFramesTracking() { if (options instanceof SentryAndroidOptions) { final SentryFrameMetricsCollector collector = ((SentryAndroidOptions) options).getFrameMetricsCollector(); - if (frameDelayCollector.start(collector)) { - logger.log(SentryLevel.INFO, "RNSentryFrameDelayCollector installed."); + if (collector != null) { + // Register a no-op listener to ensure frame metrics collection is active. + // This is needed so that getFramesDelay() has data to query. + stopFrameMetricsCollection(); + frameMetricsCollector = collector; + frameMetricsListenerId = + collector.startCollection( + (startNanos, endNanos, durationNanos, delayNanos, isSlow, isFrozen, refreshRate) + -> {}); + if (frameMetricsListenerId != null) { + logger.log(SentryLevel.INFO, "SentryFrameMetricsCollector listener installed."); + } } } } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case - logger.log(SentryLevel.WARNING, "Error starting RNSentryFrameDelayCollector."); + logger.log(SentryLevel.WARNING, "Error starting frame metrics collection."); } } @@ -761,7 +779,15 @@ public void disableNativeFramesTracking() { frameMetricsAggregator.stop(); frameMetricsAggregator = null; } - frameDelayCollector.stop(); + stopFrameMetricsCollection(); + } + + private void stopFrameMetricsCollection() { + if (frameMetricsCollector != null && frameMetricsListenerId != null) { + frameMetricsCollector.stopCollection(frameMetricsListenerId); + } + frameMetricsCollector = null; + frameMetricsListenerId = null; } public void getNewScreenTimeToDisplay(Promise promise) { From ea9d0b63e7cb16175bd816cc0c7a6a254f8618ee Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Apr 2026 11:27:39 +0200 Subject: [PATCH 2/7] Add chanfgelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3dce4d70..1a21c7c977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Escape `name` and `version` values when injecting release constants into the web bundle ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044)) - Mask the Sentry auth token in the `sentry.gradle` upload-task lifecycle log ([#6057](https://github.com/getsentry/sentry-react-native/pull/6057)) - Discard invalid navigation/interaction transactions via an event processor instead of mutating the internal `_sampled` flag, removing misleading "dropped due to sampling" debug logs ([#6051](https://github.com/getsentry/sentry-react-native/pull/6051)) +- Use sentry-java `getFramesDelay` API instead of custom frame delay collector ([#6074](https://github.com/getsentry/sentry-react-native/pull/6074)) ### Dependencies From 3984ffe455c71bedf9db0fcadcd1e6129fcfe934 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 4 May 2026 13:23:14 +0200 Subject: [PATCH 3/7] Update CHANGELOG for sentry-java API usage Updated the changelog to reflect the use of sentry-java's getFramesDelay API and removed duplicate entry. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b21972a66..6dc2e2964c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Fixes + +- Use sentry-java `getFramesDelay` API instead of custom frame delay collector ([#6074](https://github.com/getsentry/sentry-react-native/pull/6074)) + ## 8.10.0 ### Features @@ -23,7 +29,6 @@ - Escape `name` and `version` values when injecting release constants into the web bundle ([#6044](https://github.com/getsentry/sentry-react-native/pull/6044)) - Mask the Sentry auth token in the `sentry.gradle` upload-task lifecycle log ([#6057](https://github.com/getsentry/sentry-react-native/pull/6057)) - Discard invalid navigation/interaction transactions via an event processor instead of mutating the internal `_sampled` flag, removing misleading "dropped due to sampling" debug logs ([#6051](https://github.com/getsentry/sentry-react-native/pull/6051)) -- Use sentry-java `getFramesDelay` API instead of custom frame delay collector ([#6074](https://github.com/getsentry/sentry-react-native/pull/6074)) ### Dependencies From 6b4adeee81e8b89ad0e21bd1ce4d280ee816a459 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:02:07 +0200 Subject: [PATCH 4/7] style(android): Fix java formatting for RNSentryModuleImpl Co-Authored-By: Claude Opus 4.6 --- .../java/io/sentry/react/RNSentryModuleImpl.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index c255439416..bf0465dce1 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -45,10 +45,10 @@ import io.sentry.android.core.InternalSentrySdk; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.SentryFramesDelayResult; import io.sentry.android.core.SentryShakeDetector; import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; -import io.sentry.android.core.SentryFramesDelayResult; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.Geo; @@ -420,8 +420,7 @@ public void fetchNativeFramesDelay( return; } - SentryFramesDelayResult result = - frameMetricsCollector.getFramesDelay(startNanos, endNanos); + SentryFramesDelayResult result = frameMetricsCollector.getFramesDelay(startNanos, endNanos); if (result.getDelaySeconds() >= 0) { promise.resolve(result.getDelaySeconds()); } else { @@ -762,8 +761,13 @@ public void enableNativeFramesTracking() { frameMetricsCollector = collector; frameMetricsListenerId = collector.startCollection( - (startNanos, endNanos, durationNanos, delayNanos, isSlow, isFrozen, refreshRate) - -> {}); + (startNanos, + endNanos, + durationNanos, + delayNanos, + isSlow, + isFrozen, + refreshRate) -> {}); if (frameMetricsListenerId != null) { logger.log(SentryLevel.INFO, "SentryFrameMetricsCollector listener installed."); } From af43c22b55171c89c2df50b0f62f3f21f61656b9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:13:29 +0200 Subject: [PATCH 5/7] test(android): Add unit tests for fetchNativeFramesDelay Tests cover: null collector, valid delay result, negative delay result, future timestamps, and zero delay with no slow frames. Co-Authored-By: Claude Opus 4.6 --- .../io/sentry/react/RNSentryModuleImpl.java | 2 +- .../sentry/react/RNSentryFramesDelayTest.java | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index bf0465dce1..a4c5499084 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -99,7 +99,7 @@ public class RNSentryModuleImpl { private final ReactApplicationContext reactApplicationContext; private final PackageInfo packageInfo; private FrameMetricsAggregator frameMetricsAggregator = null; - private @Nullable SentryFrameMetricsCollector frameMetricsCollector = null; + @VisibleForTesting @Nullable SentryFrameMetricsCollector frameMetricsCollector = null; private @Nullable String frameMetricsListenerId = null; private boolean androidXAvailable; diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java new file mode 100644 index 0000000000..f185a2b488 --- /dev/null +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java @@ -0,0 +1,90 @@ +package io.sentry.react; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import org.junit.Before; +import org.junit.Test; + +public class RNSentryFramesDelayTest { + + private RNSentryModuleImpl module; + private Promise promise; + + @Before + public void setUp() throws Exception { + ReactApplicationContext reactContext = mock(ReactApplicationContext.class); + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo()); + when(reactContext.getPackageManager()).thenReturn(packageManager); + when(reactContext.getPackageName()).thenReturn("com.test.app"); + module = new RNSentryModuleImpl(reactContext); + promise = mock(Promise.class); + } + + @Test + public void resolvesNullWhenCollectorIsNull() { + module.frameMetricsCollector = null; + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now - 1.0, now, promise); + verify(promise).resolve(isNull()); + } + + @Test + public void resolvesDelayFromCollector() { + SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); + when(collector.getFramesDelay(anyLong(), anyLong())) + .thenReturn(new SentryFramesDelayResult(0.123, 2)); + module.frameMetricsCollector = collector; + + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now - 1.0, now, promise); + verify(promise).resolve(eq(0.123)); + } + + @Test + public void resolvesNullWhenDelayIsNegative() { + SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); + when(collector.getFramesDelay(anyLong(), anyLong())) + .thenReturn(new SentryFramesDelayResult(-1, 0)); + module.frameMetricsCollector = collector; + + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now - 1.0, now, promise); + verify(promise).resolve(isNull()); + } + + @Test + public void resolvesNullWhenStartIsInFuture() { + SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); + module.frameMetricsCollector = collector; + + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now + 100.0, now + 200.0, promise); + verify(promise).resolve(isNull()); + } + + @Test + public void resolvesZeroDelayWhenNoSlowFrames() { + SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); + when(collector.getFramesDelay(anyLong(), anyLong())) + .thenReturn(new SentryFramesDelayResult(0.0, 0)); + module.frameMetricsCollector = collector; + + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now - 1.0, now, promise); + verify(promise).resolve(eq(0.0)); + } +} From 6c22bb51a512d59a7479de3e5dc8f4c473fcd21d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:15:15 +0200 Subject: [PATCH 6/7] fix(android): Add defensive null check on getFramesDelay result Co-Authored-By: Claude Opus 4.6 --- .../main/java/io/sentry/react/RNSentryModuleImpl.java | 2 +- .../java/io/sentry/react/RNSentryFramesDelayTest.java | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index a4c5499084..6bcdcb1cac 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -421,7 +421,7 @@ public void fetchNativeFramesDelay( } SentryFramesDelayResult result = frameMetricsCollector.getFramesDelay(startNanos, endNanos); - if (result.getDelaySeconds() >= 0) { + if (result != null && result.getDelaySeconds() >= 0) { promise.resolve(result.getDelaySeconds()); } else { promise.resolve(null); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java index f185a2b488..976dee2774 100644 --- a/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryFramesDelayTest.java @@ -66,6 +66,17 @@ public void resolvesNullWhenDelayIsNegative() { verify(promise).resolve(isNull()); } + @Test + public void resolvesNullWhenResultIsNull() { + SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); + when(collector.getFramesDelay(anyLong(), anyLong())).thenReturn(null); + module.frameMetricsCollector = collector; + + double now = System.currentTimeMillis() / 1e3; + module.fetchNativeFramesDelay(now - 1.0, now, promise); + verify(promise).resolve(isNull()); + } + @Test public void resolvesNullWhenStartIsInFuture() { SentryFrameMetricsCollector collector = mock(SentryFrameMetricsCollector.class); From dfe56da1a36ae969765f681865234a24c96ad50c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 10:20:13 +0200 Subject: [PATCH 7/7] fix(android): Only set frameMetricsCollector when startCollection succeeds Prevents getFramesDelay() from being called without an active listener when startCollection() returns null. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/io/sentry/react/RNSentryModuleImpl.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 6bcdcb1cac..f2f492bd8d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -758,8 +758,7 @@ public void enableNativeFramesTracking() { // Register a no-op listener to ensure frame metrics collection is active. // This is needed so that getFramesDelay() has data to query. stopFrameMetricsCollection(); - frameMetricsCollector = collector; - frameMetricsListenerId = + String listenerId = collector.startCollection( (startNanos, endNanos, @@ -768,7 +767,9 @@ public void enableNativeFramesTracking() { isSlow, isFrozen, refreshRate) -> {}); - if (frameMetricsListenerId != null) { + if (listenerId != null) { + frameMetricsCollector = collector; + frameMetricsListenerId = listenerId; logger.log(SentryLevel.INFO, "SentryFrameMetricsCollector listener installed."); } }