Skip to content

Commit aec926c

Browse files
authored
Merge branch 'main' into rz/chore/feedback-button-deprecate
2 parents 5efbaa6 + 7659fe5 commit aec926c

4 files changed

Lines changed: 291 additions & 0 deletions

File tree

sentry-android-core/api/sentry-android-core.api

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,12 @@ public abstract interface class io/sentry/android/core/SentryAndroidOptions$Befo
435435
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z
436436
}
437437

438+
public final class io/sentry/android/core/SentryFramesDelayResult {
439+
public fun <init> (DI)V
440+
public fun getDelaySeconds ()D
441+
public fun getFramesContributingToDelayCount ()I
442+
}
443+
438444
public final class io/sentry/android/core/SentryInitProvider {
439445
public fun <init> ()V
440446
public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.sentry.android.core;
2+
3+
import org.jetbrains.annotations.ApiStatus;
4+
5+
/** Result of querying frame delay for a given time range. */
6+
@ApiStatus.Internal
7+
public final class SentryFramesDelayResult {
8+
9+
private final double delaySeconds;
10+
private final int framesContributingToDelayCount;
11+
12+
public SentryFramesDelayResult(
13+
final double delaySeconds, final int framesContributingToDelayCount) {
14+
this.delaySeconds = delaySeconds;
15+
this.framesContributingToDelayCount = framesContributingToDelayCount;
16+
}
17+
18+
/**
19+
* @return the total frame delay in seconds, or -1 if incalculable (e.g. no frame data available)
20+
*/
21+
public double getDelaySeconds() {
22+
return delaySeconds;
23+
}
24+
25+
/**
26+
* @return the number of frames that contributed to the delay (slow + frozen frames)
27+
*/
28+
public int getFramesContributingToDelayCount() {
29+
return framesContributingToDelayCount;
30+
}
31+
}

sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919
import io.sentry.SentryUUID;
2020
import io.sentry.android.core.BuildInfoProvider;
2121
import io.sentry.android.core.ContextUtils;
22+
import io.sentry.android.core.SentryFramesDelayResult;
2223
import io.sentry.util.Objects;
2324
import java.lang.ref.WeakReference;
2425
import java.lang.reflect.Field;
26+
import java.util.Iterator;
2527
import java.util.Map;
2628
import java.util.Set;
2729
import java.util.concurrent.ConcurrentHashMap;
30+
import java.util.concurrent.ConcurrentSkipListSet;
2831
import java.util.concurrent.CopyOnWriteArraySet;
2932
import java.util.concurrent.TimeUnit;
3033
import org.jetbrains.annotations.ApiStatus;
@@ -35,6 +38,8 @@
3538
public final class SentryFrameMetricsCollector implements Application.ActivityLifecycleCallbacks {
3639
private static final long oneSecondInNanos = TimeUnit.SECONDS.toNanos(1);
3740
private static final long frozenFrameThresholdNanos = TimeUnit.MILLISECONDS.toNanos(700);
41+
private static final int MAX_FRAMES_COUNT = 3600;
42+
private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes
3843

3944
private final @NotNull BuildInfoProvider buildInfoProvider;
4045
private final @NotNull Set<Window> trackedWindows = new CopyOnWriteArraySet<>();
@@ -53,6 +58,10 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi
5358
private long lastFrameStartNanos = 0;
5459
private long lastFrameEndNanos = 0;
5560

61+
// frame buffer for getFramesDelay queries, sorted by frame end time
62+
private final @NotNull ConcurrentSkipListSet<DelayedFrame> delayedFrames =
63+
new ConcurrentSkipListSet<>();
64+
5665
@SuppressLint("NewApi")
5766
public SentryFrameMetricsCollector(
5867
final @NotNull Context context,
@@ -177,6 +186,16 @@ public SentryFrameMetricsCollector(
177186
isSlow(cpuDuration, (long) ((float) oneSecondInNanos / (refreshRate - 1.0f)));
178187
final boolean isFrozen = isSlow && isFrozen(cpuDuration);
179188

189+
final long frameStartTime = startTime;
190+
191+
// store frames with delay for getFramesDelay queries
192+
if (delayNanos > 0) {
193+
pruneOldFrames(lastFrameEndNanos);
194+
if (delayedFrames.size() < MAX_FRAMES_COUNT) {
195+
delayedFrames.add(new DelayedFrame(frameStartTime, lastFrameEndNanos, delayNanos));
196+
}
197+
}
198+
180199
for (FrameMetricsCollectorListener l : listenerMap.values()) {
181200
l.onFrameMetricCollected(
182201
startTime,
@@ -354,6 +373,89 @@ public long getLastKnownFrameStartTimeNanos() {
354373
return -1;
355374
}
356375

376+
/**
377+
* Queries the frame delay for a given time range.
378+
*
379+
* <p>This is useful for external consumers (e.g. React Native SDK) that need to query frame delay
380+
* for an arbitrary time range without registering their own frame listener.
381+
*
382+
* @param startSystemNanos start of the time range in {@link System#nanoTime()} units
383+
* @param endSystemNanos end of the time range in {@link System#nanoTime()} units
384+
* @return a {@link SentryFramesDelayResult} with the delay in seconds and the number of frames
385+
* contributing to delay, or a result with delaySeconds=-1 if incalculable
386+
*/
387+
public @NotNull SentryFramesDelayResult getFramesDelay(
388+
final long startSystemNanos, final long endSystemNanos) {
389+
if (!isAvailable) {
390+
return new SentryFramesDelayResult(-1, 0);
391+
}
392+
393+
if (endSystemNanos <= startSystemNanos) {
394+
return new SentryFramesDelayResult(-1, 0);
395+
}
396+
397+
long totalDelayNanos = 0;
398+
int delayFrameCount = 0;
399+
400+
if (!delayedFrames.isEmpty()) {
401+
final Iterator<DelayedFrame> iterator =
402+
delayedFrames.tailSet(new DelayedFrame(startSystemNanos)).iterator();
403+
404+
while (iterator.hasNext()) {
405+
final @NotNull DelayedFrame frame = iterator.next();
406+
407+
if (frame.startNanos >= endSystemNanos) {
408+
break;
409+
}
410+
411+
// The delay portion of a frame is at the end: [frameEnd - delay, frameEnd]
412+
final long delayStart = frame.endNanos - frame.delayNanos;
413+
final long delayEnd = frame.endNanos;
414+
415+
// Intersect the delay interval with the query range
416+
final long overlapStart = Math.max(delayStart, startSystemNanos);
417+
final long overlapEnd = Math.min(delayEnd, endSystemNanos);
418+
419+
if (overlapEnd > overlapStart) {
420+
totalDelayNanos += (overlapEnd - overlapStart);
421+
delayFrameCount++;
422+
}
423+
}
424+
}
425+
426+
final double delaySeconds = totalDelayNanos / 1e9d;
427+
return new SentryFramesDelayResult(delaySeconds, delayFrameCount);
428+
}
429+
430+
private void pruneOldFrames(final long currentNanos) {
431+
final long cutoff = currentNanos - MAX_FRAME_AGE_NANOS;
432+
delayedFrames.headSet(new DelayedFrame(cutoff)).clear();
433+
}
434+
435+
private static class DelayedFrame implements Comparable<DelayedFrame> {
436+
final long startNanos;
437+
final long endNanos;
438+
final long delayNanos;
439+
440+
/** Sentinel constructor for set range queries (tailSet/headSet). */
441+
DelayedFrame(final long timestampNanos) {
442+
this(timestampNanos, timestampNanos, 0);
443+
}
444+
445+
DelayedFrame(final long startNanos, final long endNanos, final long delayNanos) {
446+
this.startNanos = startNanos;
447+
this.endNanos = endNanos;
448+
this.delayNanos = delayNanos;
449+
}
450+
451+
@Override
452+
public int compareTo(final @NotNull DelayedFrame o) {
453+
int cmp = Long.compare(this.endNanos, o.endNanos);
454+
if (cmp != 0) return cmp;
455+
return Long.compare(this.startNanos, o.startNanos);
456+
}
457+
}
458+
357459
@ApiStatus.Internal
358460
public interface FrameMetricsCollectorListener {
359461
/**

sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,158 @@ class SentryFrameMetricsCollectorTest {
577577
assertEquals(0, collector.getProperty<Set<Window>>("trackedWindows").size)
578578
}
579579

580+
@Test
581+
fun `getFramesDelay returns -1 when not available`() {
582+
val buildInfo =
583+
mock<BuildInfoProvider> { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) }
584+
val collector = fixture.getSut(context, buildInfo)
585+
586+
val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1))
587+
assertEquals(-1.0, result.delaySeconds)
588+
assertEquals(0, result.framesContributingToDelayCount)
589+
}
590+
591+
@Test
592+
fun `getFramesDelay returns -1 for invalid time range`() {
593+
val collector = fixture.getSut(context)
594+
595+
val result = collector.getFramesDelay(2000, 1000)
596+
assertEquals(-1.0, result.delaySeconds)
597+
assertEquals(0, result.framesContributingToDelayCount)
598+
}
599+
600+
@Test
601+
fun `getFramesDelay returns zero delay when no slow frames recorded`() {
602+
val buildInfo =
603+
mock<BuildInfoProvider> { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) }
604+
val collector = fixture.getSut(context, buildInfo)
605+
Shadows.shadowOf(Looper.getMainLooper()).idle()
606+
val listener =
607+
collector.getProperty<Window.OnFrameMetricsAvailableListener>("frameMetricsAvailableListener")
608+
609+
collector.startCollection(mock())
610+
611+
// emit a fast frame (21ns cpu time — well under 16ms budget)
612+
listener.onFrameMetricsAvailable(createMockWindow(), createMockFrameMetrics(), 0)
613+
614+
// choreographer is at end of range so no pending delay
615+
val choreographer = collector.getProperty<Choreographer>("choreographer")
616+
choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(1))
617+
618+
val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1))
619+
assertEquals(0.0, result.delaySeconds)
620+
assertEquals(0, result.framesContributingToDelayCount)
621+
}
622+
623+
@Test
624+
fun `getFramesDelay calculates delay from slow frames`() {
625+
val buildInfo =
626+
mock<BuildInfoProvider> { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) }
627+
val collector = fixture.getSut(context, buildInfo)
628+
val listener =
629+
collector.getProperty<Window.OnFrameMetricsAvailableListener>("frameMetricsAvailableListener")
630+
631+
collector.startCollection(mock())
632+
633+
// emit a slow frame (~100ms extra = ~116ms total, well over 16ms budget)
634+
listener.onFrameMetricsAvailable(
635+
createMockWindow(),
636+
createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100)),
637+
0,
638+
)
639+
640+
// emit a frozen frame (~1000ms extra = ~1016ms total, well over 700ms)
641+
listener.onFrameMetricsAvailable(
642+
createMockWindow(),
643+
createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(1000)),
644+
0,
645+
)
646+
647+
// choreographer is at end of range so no pending delay
648+
Shadows.shadowOf(Looper.getMainLooper()).idle()
649+
val choreographer = collector.getProperty<Choreographer>("choreographer")
650+
choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5))
651+
652+
val result = collector.getFramesDelay(0, TimeUnit.SECONDS.toNanos(5))
653+
assertTrue(result.delaySeconds > 0)
654+
assertEquals(2, result.framesContributingToDelayCount)
655+
}
656+
657+
@Test
658+
fun `getFramesDelay handles partial frame overlap`() {
659+
val buildInfo =
660+
mock<BuildInfoProvider> { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) }
661+
val collector = fixture.getSut(context, buildInfo)
662+
val listener =
663+
collector.getProperty<Window.OnFrameMetricsAvailableListener>("frameMetricsAvailableListener")
664+
665+
collector.startCollection(mock())
666+
667+
// emit a frozen frame (~1s)
668+
listener.onFrameMetricsAvailable(
669+
createMockWindow(),
670+
createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.SECONDS.toNanos(1)),
671+
0,
672+
)
673+
674+
// choreographer is at end of range
675+
Shadows.shadowOf(Looper.getMainLooper()).idle()
676+
val choreographer = collector.getProperty<Choreographer>("choreographer")
677+
choreographer.injectForField("mLastFrameTimeNanos", TimeUnit.SECONDS.toNanos(5))
678+
679+
// The frame's delay interval is roughly [~16ms, ~1000ms].
680+
// Query from 500ms so the range clips the delay interval in half.
681+
val queryStart = TimeUnit.MILLISECONDS.toNanos(500)
682+
val queryEnd = TimeUnit.SECONDS.toNanos(5)
683+
684+
val fullResult = collector.getFramesDelay(0, queryEnd)
685+
val partialResult = collector.getFramesDelay(queryStart, queryEnd)
686+
687+
// partial overlap should yield less delay than the full range
688+
assertTrue(partialResult.delaySeconds > 0)
689+
assertTrue(partialResult.delaySeconds < fullResult.delaySeconds)
690+
assertEquals(1, partialResult.framesContributingToDelayCount)
691+
}
692+
693+
@Test
694+
fun `old frames are automatically pruned`() {
695+
val buildInfo =
696+
mock<BuildInfoProvider> { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) }
697+
val collector = fixture.getSut(context, buildInfo)
698+
Shadows.shadowOf(Looper.getMainLooper()).idle()
699+
val listener =
700+
collector.getProperty<Window.OnFrameMetricsAvailableListener>("frameMetricsAvailableListener")
701+
val choreographer = collector.getProperty<Choreographer>("choreographer")
702+
703+
collector.startCollection(mock())
704+
705+
val t0 = TimeUnit.MINUTES.toNanos(10) // start at a realistic base time
706+
707+
// emit a slow frame at t0
708+
val frameMetrics1 =
709+
createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100))
710+
whenever(frameMetrics1.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t0)
711+
listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics1, 0)
712+
713+
choreographer.injectForField("mLastFrameTimeNanos", t0 + TimeUnit.SECONDS.toNanos(1))
714+
715+
// verify frame exists
716+
val resultBefore = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1))
717+
assertEquals(1, resultBefore.framesContributingToDelayCount)
718+
719+
// emit another slow frame >5 minutes later to trigger auto-pruning
720+
val t1 = t0 + TimeUnit.MINUTES.toNanos(6)
721+
val frameMetrics2 =
722+
createMockFrameMetrics(extraCpuDurationNanos = TimeUnit.MILLISECONDS.toNanos(100))
723+
whenever(frameMetrics2.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP)).thenReturn(t1)
724+
listener.onFrameMetricsAvailable(createMockWindow(), frameMetrics2, 0)
725+
726+
// the first frame should have been pruned (>5min old)
727+
choreographer.injectForField("mLastFrameTimeNanos", t1 + TimeUnit.SECONDS.toNanos(1))
728+
val resultAfter = collector.getFramesDelay(t0, t0 + TimeUnit.SECONDS.toNanos(1))
729+
assertEquals(0, resultAfter.framesContributingToDelayCount)
730+
}
731+
580732
private fun createMockWindow(refreshRate: Float = 60F): Window {
581733
val mockWindow = mock<Window>()
582734
val mockDisplay = mock<Display>()

0 commit comments

Comments
 (0)