diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..8af0182bb45 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -435,6 +435,12 @@ public abstract interface class io/sentry/android/core/SentryAndroidOptions$Befo public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z } +public final class io/sentry/android/core/SentryFramesDelayResult { + public fun (DI)V + public fun getDelaySeconds ()D + public fun getFramesContributingToDelayCount ()I +} + public final class io/sentry/android/core/SentryInitProvider { public fun ()V public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java new file mode 100644 index 00000000000..724d8446ea8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java @@ -0,0 +1,31 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; + +/** Result of querying frame delay for a given time range. */ +@ApiStatus.Internal +public final class SentryFramesDelayResult { + + private final double delaySeconds; + private final int framesContributingToDelayCount; + + public SentryFramesDelayResult( + final double delaySeconds, final int framesContributingToDelayCount) { + this.delaySeconds = delaySeconds; + this.framesContributingToDelayCount = framesContributingToDelayCount; + } + + /** + * @return the total frame delay in seconds, or -1 if incalculable (e.g. no frame data available) + */ + public double getDelaySeconds() { + return delaySeconds; + } + + /** + * @return the number of frames that contributed to the delay (slow + frozen frames) + */ + public int getFramesContributingToDelayCount() { + return framesContributingToDelayCount; + } +} 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 55342c0e4c0..3e64f4c87ee 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 @@ -19,12 +19,15 @@ 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.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; +import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -35,6 +38,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks { private static final long oneSecondInNanos = TimeUnit.SECONDS.toNanos(1); private static final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700); + private static final int MAX_FRAMES_COUNT = 3600; + private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); @@ -53,6 +58,10 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private long lastFrameStartNanos = 0; private long lastFrameEndNanos = 0; + // frame buffer for getFramesDelay queries, sorted by frame end time + private final @NotNull ConcurrentSkipListSet delayedFrames = + new ConcurrentSkipListSet<>(); + @SuppressLint("NewApi") public SentryFrameMetricsCollector( final @NotNull Context context, @@ -177,6 +186,16 @@ public SentryFrameMetricsCollector( isSlow(cpuDuration, (long) ((float) oneSecondInNanos / (refreshRate - 1.0f))); final boolean isFrozen = isSlow && isFrozen(cpuDuration); + final long frameStartTime = startTime; + + // store frames with delay for getFramesDelay queries + if (delayNanos > 0) { + if (delayedFrames.size() <= MAX_FRAMES_COUNT) { + delayedFrames.add(new DelayedFrame(frameStartTime, lastFrameEndNanos, delayNanos)); + } + pruneOldFrames(lastFrameEndNanos); + } + for (FrameMetricsCollectorListener l : listenerMap.values()) { l.onFrameMetricCollected( startTime, @@ -354,6 +373,87 @@ public long getLastKnownFrameStartTimeNanos() { return -1; } + /** + * Queries the frame delay for a given time range. + * + *

This is useful for external consumers (e.g. React Native SDK) that need to query frame delay + * for an arbitrary time range without registering their own frame listener. + * + * @param startSystemNanos start of the time range in {@link System#nanoTime()} units + * @param endSystemNanos end of the time range in {@link System#nanoTime()} units + * @return a {@link SentryFramesDelayResult} with the delay in seconds and the number of frames + * contributing to delay, or a result with delaySeconds=-1 if incalculable + */ + public @NotNull SentryFramesDelayResult getFramesDelay( + final long startSystemNanos, final long endSystemNanos) { + if (!isAvailable) { + return new SentryFramesDelayResult(-1, 0); + } + + if (endSystemNanos <= startSystemNanos) { + return new SentryFramesDelayResult(-1, 0); + } + + long totalDelayNanos = 0; + int delayFrameCount = 0; + + if (!delayedFrames.isEmpty()) { + final Iterator iterator = + delayedFrames.tailSet(new DelayedFrame(startSystemNanos)).iterator(); + + while (iterator.hasNext()) { + final @NotNull DelayedFrame frame = iterator.next(); + + if (frame.startNanos >= endSystemNanos) { + break; + } + + // The delay portion of a frame is at the end: [frameEnd - delay, frameEnd] + final long delayStart = frame.endNanos - frame.delayNanos; + final long delayEnd = frame.endNanos; + + // Intersect the delay interval with the query range + final long overlapStart = Math.max(delayStart, startSystemNanos); + final long overlapEnd = Math.min(delayEnd, endSystemNanos); + + if (overlapEnd > overlapStart) { + totalDelayNanos += (overlapEnd - overlapStart); + delayFrameCount++; + } + } + } + + final double delaySeconds = totalDelayNanos / 1e9d; + return new SentryFramesDelayResult(delaySeconds, delayFrameCount); + } + + private void pruneOldFrames(final long currentNanos) { + final long cutoff = currentNanos - MAX_FRAME_AGE_NANOS; + delayedFrames.headSet(new DelayedFrame(cutoff)).clear(); + } + + private static class DelayedFrame implements Comparable { + final long startNanos; + final long endNanos; + final long delayNanos; + + /** Sentinel constructor for set range queries (tailSet/headSet). */ + DelayedFrame(final long timestampNanos) { + this(timestampNanos, timestampNanos, 0); + } + + DelayedFrame(final long startNanos, final long endNanos, final long delayNanos) { + this.startNanos = startNanos; + this.endNanos = endNanos; + this.delayNanos = delayNanos; + } + + @Override + public int compareTo(final @NotNull DelayedFrame o) { + return Long.compare(this.endNanos, o.endNanos); + } + } + @ApiStatus.Internal public interface FrameMetricsCollectorListener { /** 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 b3b018e87b2..053779b0a3c 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 @@ -577,6 +577,152 @@ class SentryFrameMetricsCollectorTest { assertEquals(0, collector.getProperty>("trackedWindows").size) } + @Test + fun `getFramesDelay returns -1 when not available`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) } + val collector = fixture.getSut(context, buildInfo) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns -1 for invalid time range`() { + val collector = fixture.getSut(context) + + val result = collector.getFramesDelay(2000, 1000) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns zero delay when no slow frames recorded`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a fast frame (21ns cpu time — well under 16ms budget) + listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0) + + // choreographer is at end of range so no pending delay + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(1)) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(0.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay calculates delay from slow frames`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)), + 0, + ) + + // emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000)), + 0, + ) + + // choreographer is at end of range so no pending delay + Shadows.shadowOf(Looper.getMainLooper()).idle() + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) + + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5)) + assertTrue(result.delaySeconds > 0) + assertEquals(2, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay handles partial frame overlap`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + val listener = + collector.getProperty("frameMetricsAvailableListener") + + collector.startCollection(mock()) + + // emit a frozen frame (~1s) + listener.onFrameMetricsAvailable( + createMockWindow(), + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.SECONDS.toNanos(1)), + 0, + ) + + // choreographer is at end of range + Shadows.shadowOf(Looper.getMainLooper()).idle() + val choreographer = collector.getProperty("choreographer") + choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5)) + + // query a range that only partially overlaps the frozen frame + // the frame starts around 50ns (INTENDED_VSYNC_TIMESTAMP), so querying from a later point + // should reduce the delay proportionally + val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5)) + assertTrue(result.delaySeconds > 0) + assertEquals(1, result.framesContributingToDelayCount) + } + + @Test + fun `old frames are automatically pruned`() { + val buildInfo = + mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) } + val collector = fixture.getSut(context, buildInfo) + Shadows.shadowOf(Looper.getMainLooper()).idle() + val listener = + collector.getProperty("frameMetricsAvailableListener") + val choreographer = collector.getProperty("choreographer") + + collector.startCollection(mock()) + + val t0 = TimeUnit.MINUTES.toNanos(10) // start at a realistic base time + + // emit a slow frame at t0 + val frameMetrics1 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics1.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t0) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0) + + choreographer.injectForField("mLastFrameTimeNanos", t0 + TimeUnit.SECONDS.toNanos(1)) + + // verify frame exists + val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(1, resultBefore.framesContributingToDelayCount) + + // emit another slow frame >5 minutes later to trigger auto-pruning + val t1 = t0 + TimeUnit.MINUTES.toNanos(6) + val frameMetrics2 = + createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)) + whenever(frameMetrics2.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t1) + listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0) + + // the first frame should have been pruned (>5min old) + choreographer.injectForField("mLastFrameTimeNanos", t1 + TimeUnit.SECONDS.toNanos(1)) + val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1)) + assertEquals(0, resultAfter.framesContributingToDelayCount) + } + private fun createMockWindow(refreshRate: Float = 60F): Window { val mockWindow = mock() val mockDisplay = mock()