From 44e21df43a5a5aa683551a693c7cb2148d26b1fa Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 12:43:44 +0200 Subject: [PATCH 1/4] feat(android): Add queryable getFramesDelay API to SpanFrameMetricsCollector Expose a getFramesDelay(startNanos, endNanos) method that allows external consumers (e.g. React Native SDK) to query frame delay for arbitrary time ranges without registering a duplicate frame listener. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/sentry-android-core.api | 9 + .../core/AndroidOptionsInitializer.java | 6 +- .../android/core/SentryAndroidOptions.java | 13 ++ .../android/core/SentryFramesDelayResult.java | 31 +++ .../core/SpanFrameMetricsCollector.java | 146 +++++++++---- .../core/SpanFrameMetricsCollectorTest.kt | 203 ++++++++++++++++++ 6 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryFramesDelayResult.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..1a1d5c057d1 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -369,6 +369,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkHandlerStrategy ()I public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions; + public fun getSpanFrameMetricsCollector ()Lio/sentry/android/core/SpanFrameMetricsCollector; public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z public fun isAnrProfilingEnabled ()Z @@ -428,6 +429,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setNativeSdkName (Ljava/lang/String;)V public fun setReportHistoricalAnrs (Z)V public fun setReportHistoricalTombstones (Z)V + public fun setSpanFrameMetricsCollector (Lio/sentry/android/core/SpanFrameMetricsCollector;)V public fun setTombstoneEnabled (Z)V } @@ -435,6 +437,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 @@ -520,6 +528,7 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun clear ()V + public fun getFramesDelay (JJ)Lio/sentry/android/core/SentryFramesDelayResult; public fun onFrameMetricCollected (JJJJZZF)V public fun onSpanFinished (Lio/sentry/ISpan;)V public fun onSpanStarted (Lio/sentry/ISpan;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 5f7fad69b5d..c9da007f7ff 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -251,12 +251,14 @@ static void initializeIntegrationsAndProcessors( options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); if (options.isEnablePerformanceV2()) { - options.addPerformanceCollector( + final SpanFrameMetricsCollector spanFrameMetricsCollector = new SpanFrameMetricsCollector( options, Objects.requireNonNull( options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required"))); + "options.getFrameMetricsCollector is required")); + options.addPerformanceCollector(spanFrameMetricsCollector); + options.setSpanFrameMetricsCollector(spanFrameMetricsCollector); } } if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 054e43322a2..9515954e842 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -242,6 +242,8 @@ public interface BeforeCaptureCallback { private @Nullable SentryFrameMetricsCollector frameMetricsCollector; + private @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector; + private boolean enableTombstone = false; /** @@ -674,6 +676,17 @@ public void setFrameMetricsCollector( this.frameMetricsCollector = frameMetricsCollector; } + @ApiStatus.Internal + public @Nullable SpanFrameMetricsCollector getSpanFrameMetricsCollector() { + return spanFrameMetricsCollector; + } + + @ApiStatus.Internal + public void setSpanFrameMetricsCollector( + final @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector) { + this.spanFrameMetricsCollector = spanFrameMetricsCollector; + } + public boolean isEnableAutoTraceIdGeneration() { return enableAutoTraceIdGeneration; } 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/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index a83454d29b7..c633273ec31 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -152,49 +152,11 @@ private void captureFrameMetrics(@NotNull final ISpan span) { return; } - final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); - - long frameDurationNanos = lastKnownFrameDurationNanos; - - if (!frames.isEmpty()) { - // determine relevant start in frames list - final Iterator iterator = frames.tailSet(new Frame(spanStartNanos)).iterator(); - - //noinspection WhileLoopReplaceableByForEach - while (iterator.hasNext()) { - final @NotNull Frame frame = iterator.next(); - - if (frame.startNanos > spanEndNanos) { - break; - } - - if (frame.startNanos >= spanStartNanos && frame.endNanos <= spanEndNanos) { - // if the frame is contained within the span, add it 1:1 to the span metrics - frameMetrics.addFrame( - frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); - } else if ((spanStartNanos > frame.startNanos && spanStartNanos < frame.endNanos) - || (spanEndNanos > frame.startNanos && spanEndNanos < frame.endNanos)) { - // span start or end are within frame - // calculate the intersection - final long durationBeforeSpan = Math.max(0, spanStartNanos - frame.startNanos); - final long delayBeforeSpan = - Math.max(0, durationBeforeSpan - frame.expectedDurationNanos); - final long delayWithinSpan = - Math.min(frame.delayNanos - delayBeforeSpan, spanDurationNanos); - - final long frameStart = Math.max(spanStartNanos, frame.startNanos); - final long frameEnd = Math.min(spanEndNanos, frame.endNanos); - final long frameDuration = frameEnd - frameStart; - frameMetrics.addFrame( - frameDuration, - delayWithinSpan, - SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), - SentryFrameMetricsCollector.isFrozen(frameDuration)); - } - - frameDurationNanos = frame.expectedDurationNanos; - } - } + // effectiveFrameDuration tracks the expected frame duration of the last frame + // iterated within the span's time range, falling back to lastKnownFrameDurationNanos + final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos}; + final @NotNull SentryFrameMetrics frameMetrics = + calculateFrameMetrics(spanStartNanos, spanEndNanos, effectiveFrameDuration); int totalFrameCount = frameMetrics.getSlowFrozenFrameCount(); @@ -204,9 +166,9 @@ private void captureFrameMetrics(@NotNull final ISpan span) { if (nextScheduledFrameNanos != -1) { totalFrameCount += addPendingFrameDelay( - frameMetrics, frameDurationNanos, spanEndNanos, nextScheduledFrameNanos); + frameMetrics, effectiveFrameDuration[0], spanEndNanos, nextScheduledFrameNanos); totalFrameCount += - interpolateFrameCount(frameMetrics, frameDurationNanos, spanDurationNanos); + interpolateFrameCount(frameMetrics, effectiveFrameDuration[0], spanDurationNanos); } final long frameDelayNanos = frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); @@ -226,6 +188,100 @@ private void captureFrameMetrics(@NotNull final ISpan span) { } } + /** + * Queries the frame delay for a given time range, without requiring an active span. + * + *

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 (!enabled) { + return new SentryFramesDelayResult(-1, 0); + } + + final long durationNanos = endSystemNanos - startSystemNanos; + if (durationNanos <= 0) { + return new SentryFramesDelayResult(-1, 0); + } + + final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos}; + final @NotNull SentryFrameMetrics frameMetrics = + calculateFrameMetrics(startSystemNanos, endSystemNanos, effectiveFrameDuration); + + final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos(); + if (nextScheduledFrameNanos != -1) { + addPendingFrameDelay( + frameMetrics, effectiveFrameDuration[0], endSystemNanos, nextScheduledFrameNanos); + } + + final long frameDelayNanos = + frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); + final double frameDelayInSeconds = frameDelayNanos / 1e9d; + + return new SentryFramesDelayResult(frameDelayInSeconds, frameMetrics.getSlowFrozenFrameCount()); + } + + /** + * Calculates frame metrics for a given time range by iterating over stored frames and handling + * partial overlaps at the boundaries. + * + * @param startNanos start of the time range + * @param endNanos end of the time range + * @param effectiveFrameDuration a single-element array that will be updated with the expected + * frame duration of the last iterated frame (used for pending delay / interpolation) + */ + private @NotNull SentryFrameMetrics calculateFrameMetrics( + final long startNanos, final long endNanos, final long @NotNull [] effectiveFrameDuration) { + final long durationNanos = endNanos - startNanos; + final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); + + if (!frames.isEmpty()) { + final Iterator iterator = frames.tailSet(new Frame(startNanos)).iterator(); + + //noinspection WhileLoopReplaceableByForEach + while (iterator.hasNext()) { + final @NotNull Frame frame = iterator.next(); + + if (frame.startNanos > endNanos) { + break; + } + + if (frame.startNanos >= startNanos && frame.endNanos <= endNanos) { + // if the frame is contained within the range, add it 1:1 + frameMetrics.addFrame( + frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); + } else if ((startNanos > frame.startNanos && startNanos < frame.endNanos) + || (endNanos > frame.startNanos && endNanos < frame.endNanos)) { + // range start or end are within frame — calculate the intersection + final long durationBeforeRange = Math.max(0, startNanos - frame.startNanos); + final long delayBeforeRange = + Math.max(0, durationBeforeRange - frame.expectedDurationNanos); + final long delayWithinRange = + Math.min(frame.delayNanos - delayBeforeRange, durationNanos); + + final long frameStart = Math.max(startNanos, frame.startNanos); + final long frameEnd = Math.min(endNanos, frame.endNanos); + final long frameDuration = frameEnd - frameStart; + frameMetrics.addFrame( + frameDuration, + delayWithinRange, + SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), + SentryFrameMetricsCollector.isFrozen(frameDuration)); + } + + effectiveFrameDuration[0] = frame.expectedDurationNanos; + } + } + + return frameMetrics; + } + @Override public void clear() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index e5d7349d37c..181892fb992 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -443,4 +443,207 @@ class SpanFrameMetricsCollectorTest { assertEquals(567, a.diff(b)) } + + @Test + fun `getFramesDelay returns -1 when disabled`() { + val sut = fixture.getSut(enabled = false) + + val result = sut.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 sut = fixture.getSut() + + // when start >= end + val result = sut.getFramesDelay(2000, 1000) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns -1 for zero duration`() { + val sut = fixture.getSut() + + val result = sut.getFramesDelay(1000, 1000) + assertEquals(-1.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay returns zero delay when no frames recorded`() { + val sut = fixture.getSut() + + // given a span is running (so frames are being collected) + val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(2)) + sut.onSpanStarted(span) + + // choreographer reports a recent timestamp, so no pending delay + fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(1) + + // but no slow/frozen frames are recorded + val result = sut.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) + assertEquals(0.0, result.delaySeconds) + assertEquals(0, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay calculates delay from slow and frozen frames`() { + val sut = fixture.getSut() + + // given a span is running (so frames are being collected) + val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(2)) + sut.onSpanStarted(span) + + // and one slow frame (32ms, 16ms delay) and one frozen frame (700ms, 684ms delay) + sut.onFrameMetricCollected(100, 132, 32, 16, true, false, 60.0f) + sut.onFrameMetricCollected(200, 900, 700, 684, true, true, 60.0f) + + // choreographer reports end of range, so no pending delay + fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(2) + + val result = sut.getFramesDelay(0, TimeUnit.SECONDS.toNanos(2)) + + // delay should be (16 + 684) nanos = 700ns = 0.0000007 seconds + assertEquals(700.0 / 1e9, result.delaySeconds, 0.0001) + assertEquals(2, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay handles partial frame overlap at start`() { + val sut = fixture.getSut() + + val ms = TimeUnit.MILLISECONDS + + // given a span is running + val span = createFakeSpan(0, ms.toNanos(5000)) + sut.onSpanStarted(span) + + // and a frozen frame: 0ms-1000ms (1s duration, delay=984ms) + sut.onFrameMetricCollected( + ms.toNanos(0), + ms.toNanos(1000), + ms.toNanos(1000), + ms.toNanos(984), + true, + true, + 60.0f, + ) + + // choreographer reports end of range + fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(2000) + + // query from 500ms to 2000ms — frame partially overlaps at start + val result = sut.getFramesDelay(ms.toNanos(500), ms.toNanos(2000)) + + // durationBeforeRange = 500ms, delayBeforeRange = max(0, 500ms - 16ms) = 484ms + // delayWithinRange = min(984ms - 484ms, 1500ms) = 500ms + // frameDuration = 1000ms - 500ms = 500ms, which is frozen (> 700ms? no, 500ms) + // but it IS slow since 500ms > 16ms expected + assertEquals(500.0 / 1e3, result.delaySeconds, 0.01) + assertEquals(1, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay handles partial frame overlap at end`() { + val sut = fixture.getSut() + + val ms = TimeUnit.MILLISECONDS + + // given a span is running + val span = createFakeSpan(0, ms.toNanos(5000)) + sut.onSpanStarted(span) + + // and a frozen frame: 500ms-1500ms (1s duration, delay=984ms) + sut.onFrameMetricCollected( + ms.toNanos(500), + ms.toNanos(1500), + ms.toNanos(1000), + ms.toNanos(984), + true, + true, + 60.0f, + ) + + // choreographer reports end of range + fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(800) + + // query from 0ms to 800ms — frame partially overlaps at end + val result = sut.getFramesDelay(ms.toNanos(0), ms.toNanos(800)) + + // frame starts at 500ms (>= 0), but end 1500ms > 800ms + // partial overlap: frameStart=500ms, frameEnd=800ms, frameDuration=300ms + // durationBeforeRange = 0, delayBeforeRange = 0 + // delayWithinRange = min(984ms, 800ms) = 800ms + // 300ms > 16ms expected, so isSlow=true + assertEquals(800.0 / 1e3, result.delaySeconds, 0.01) + assertEquals(1, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay includes pending frame delay`() { + val sut = fixture.getSut() + + // given a span is running + val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(3)) + sut.onSpanStarted(span) + + // and no frames are drawn, but the choreographer reports a timestamp + fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(1) + + // query from 1s to 2s — 1s since last choreographer frame = pending frozen frame + val result = sut.getFramesDelay(TimeUnit.SECONDS.toNanos(1), TimeUnit.SECONDS.toNanos(2)) + + // pending duration = 2s - 1s = 1s, which is frozen (> 700ms) + // pending delay = 1s - 16.67ms ≈ 983.33ms + assertEquals(0.983333, result.delaySeconds, 0.01) + assertEquals(1, result.framesContributingToDelayCount) + } + + @Test + fun `getFramesDelay does not require an active span for querying`() { + val sut = fixture.getSut() + + val ms = TimeUnit.MILLISECONDS + + // given a span is running (to trigger frame collection) + val span = createFakeSpan(0, ms.toNanos(2000)) + sut.onSpanStarted(span) + + // and frames are recorded + sut.onFrameMetricCollected( + ms.toNanos(10), + ms.toNanos(42), + ms.toNanos(32), + ms.toNanos(16), + true, + false, + 60.0f, + ) + sut.onFrameMetricCollected( + ms.toNanos(50), + ms.toNanos(750), + ms.toNanos(700), + ms.toNanos(684), + true, + true, + 60.0f, + ) + + // choreographer reports end of query range + fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(1000) + + // getFramesDelay can be called independently without finishing the span + val result = sut.getFramesDelay(0, ms.toNanos(1000)) + assertEquals(700.0 / 1e3, result.delaySeconds, 0.001) + assertEquals(2, result.framesContributingToDelayCount) + + // the span is still running and can still finish normally + fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(2000) + sut.onSpanFinished(span) + verify(span).setData("frames.slow", 1) + verify(span).setData("frames.frozen", 1) + } } From 3e247bbdc279a5d37548a9d95ccc8cd8f6f4f33b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 12:44:36 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f68583a5fe..b1c4305ca18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add queryable `getFramesDelay` API to `SpanFrameMetricsCollector` for querying frame delay without a duplicate listener ([#5248](https://github.com/getsentry/sentry-java/pull/5248)) + ## 8.37.1 ### Fixes From 9f94607c8191bb332779f6a4238915b7f5393d0b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 12:49:04 +0200 Subject: [PATCH 3/4] revert changelog --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c4305ca18..6f68583a5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # Changelog -## Unreleased - -### Features - -- Add queryable `getFramesDelay` API to `SpanFrameMetricsCollector` for querying frame delay without a duplicate listener ([#5248](https://github.com/getsentry/sentry-java/pull/5248)) - ## 8.37.1 ### Fixes From 99acb6ac9e44aaf4cc656ba16f219e54cfdc2190 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 31 Mar 2026 17:06:51 +0200 Subject: [PATCH 4/4] ref(android): Move getFramesDelay to SentryFrameMetricsCollector Moves the queryable frames delay API from SpanFrameMetricsCollector to SentryFrameMetricsCollector per review feedback. This avoids coupling the API to span lifecycle, making it directly usable by hybrid SDKs via the already-accessible options.getFrameMetricsCollector(). Reverts changes to SpanFrameMetricsCollector, SentryAndroidOptions, and AndroidOptionsInitializer from the previous approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/sentry-android-core.api | 3 - .../core/AndroidOptionsInitializer.java | 6 +- .../android/core/SentryAndroidOptions.java | 13 -- .../core/SpanFrameMetricsCollector.java | 146 ++++--------- .../util/SentryFrameMetricsCollector.java | 100 +++++++++ .../core/SpanFrameMetricsCollectorTest.kt | 203 ------------------ .../util/SentryFrameMetricsCollectorTest.kt | 146 +++++++++++++ 7 files changed, 293 insertions(+), 324 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 1a1d5c057d1..8af0182bb45 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -369,7 +369,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getNativeSdkName ()Ljava/lang/String; public fun getNdkHandlerStrategy ()I public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions; - public fun getSpanFrameMetricsCollector ()Lio/sentry/android/core/SpanFrameMetricsCollector; public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z public fun isAnrProfilingEnabled ()Z @@ -429,7 +428,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setNativeSdkName (Ljava/lang/String;)V public fun setReportHistoricalAnrs (Z)V public fun setReportHistoricalTombstones (Z)V - public fun setSpanFrameMetricsCollector (Lio/sentry/android/core/SpanFrameMetricsCollector;)V public fun setTombstoneEnabled (Z)V } @@ -528,7 +526,6 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun clear ()V - public fun getFramesDelay (JJ)Lio/sentry/android/core/SentryFramesDelayResult; public fun onFrameMetricCollected (JJJJZZF)V public fun onSpanFinished (Lio/sentry/ISpan;)V public fun onSpanStarted (Lio/sentry/ISpan;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index c9da007f7ff..5f7fad69b5d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -251,14 +251,12 @@ static void initializeIntegrationsAndProcessors( options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger())); if (options.isEnablePerformanceV2()) { - final SpanFrameMetricsCollector spanFrameMetricsCollector = + options.addPerformanceCollector( new SpanFrameMetricsCollector( options, Objects.requireNonNull( options.getFrameMetricsCollector(), - "options.getFrameMetricsCollector is required")); - options.addPerformanceCollector(spanFrameMetricsCollector); - options.setSpanFrameMetricsCollector(spanFrameMetricsCollector); + "options.getFrameMetricsCollector is required"))); } } if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 9515954e842..054e43322a2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -242,8 +242,6 @@ public interface BeforeCaptureCallback { private @Nullable SentryFrameMetricsCollector frameMetricsCollector; - private @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector; - private boolean enableTombstone = false; /** @@ -676,17 +674,6 @@ public void setFrameMetricsCollector( this.frameMetricsCollector = frameMetricsCollector; } - @ApiStatus.Internal - public @Nullable SpanFrameMetricsCollector getSpanFrameMetricsCollector() { - return spanFrameMetricsCollector; - } - - @ApiStatus.Internal - public void setSpanFrameMetricsCollector( - final @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector) { - this.spanFrameMetricsCollector = spanFrameMetricsCollector; - } - public boolean isEnableAutoTraceIdGeneration() { return enableAutoTraceIdGeneration; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index c633273ec31..a83454d29b7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -152,11 +152,49 @@ private void captureFrameMetrics(@NotNull final ISpan span) { return; } - // effectiveFrameDuration tracks the expected frame duration of the last frame - // iterated within the span's time range, falling back to lastKnownFrameDurationNanos - final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos}; - final @NotNull SentryFrameMetrics frameMetrics = - calculateFrameMetrics(spanStartNanos, spanEndNanos, effectiveFrameDuration); + final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); + + long frameDurationNanos = lastKnownFrameDurationNanos; + + if (!frames.isEmpty()) { + // determine relevant start in frames list + final Iterator iterator = frames.tailSet(new Frame(spanStartNanos)).iterator(); + + //noinspection WhileLoopReplaceableByForEach + while (iterator.hasNext()) { + final @NotNull Frame frame = iterator.next(); + + if (frame.startNanos > spanEndNanos) { + break; + } + + if (frame.startNanos >= spanStartNanos && frame.endNanos <= spanEndNanos) { + // if the frame is contained within the span, add it 1:1 to the span metrics + frameMetrics.addFrame( + frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); + } else if ((spanStartNanos > frame.startNanos && spanStartNanos < frame.endNanos) + || (spanEndNanos > frame.startNanos && spanEndNanos < frame.endNanos)) { + // span start or end are within frame + // calculate the intersection + final long durationBeforeSpan = Math.max(0, spanStartNanos - frame.startNanos); + final long delayBeforeSpan = + Math.max(0, durationBeforeSpan - frame.expectedDurationNanos); + final long delayWithinSpan = + Math.min(frame.delayNanos - delayBeforeSpan, spanDurationNanos); + + final long frameStart = Math.max(spanStartNanos, frame.startNanos); + final long frameEnd = Math.min(spanEndNanos, frame.endNanos); + final long frameDuration = frameEnd - frameStart; + frameMetrics.addFrame( + frameDuration, + delayWithinSpan, + SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), + SentryFrameMetricsCollector.isFrozen(frameDuration)); + } + + frameDurationNanos = frame.expectedDurationNanos; + } + } int totalFrameCount = frameMetrics.getSlowFrozenFrameCount(); @@ -166,9 +204,9 @@ private void captureFrameMetrics(@NotNull final ISpan span) { if (nextScheduledFrameNanos != -1) { totalFrameCount += addPendingFrameDelay( - frameMetrics, effectiveFrameDuration[0], spanEndNanos, nextScheduledFrameNanos); + frameMetrics, frameDurationNanos, spanEndNanos, nextScheduledFrameNanos); totalFrameCount += - interpolateFrameCount(frameMetrics, effectiveFrameDuration[0], spanDurationNanos); + interpolateFrameCount(frameMetrics, frameDurationNanos, spanDurationNanos); } final long frameDelayNanos = frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); @@ -188,100 +226,6 @@ private void captureFrameMetrics(@NotNull final ISpan span) { } } - /** - * Queries the frame delay for a given time range, without requiring an active span. - * - *

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 (!enabled) { - return new SentryFramesDelayResult(-1, 0); - } - - final long durationNanos = endSystemNanos - startSystemNanos; - if (durationNanos <= 0) { - return new SentryFramesDelayResult(-1, 0); - } - - final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos}; - final @NotNull SentryFrameMetrics frameMetrics = - calculateFrameMetrics(startSystemNanos, endSystemNanos, effectiveFrameDuration); - - final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos(); - if (nextScheduledFrameNanos != -1) { - addPendingFrameDelay( - frameMetrics, effectiveFrameDuration[0], endSystemNanos, nextScheduledFrameNanos); - } - - final long frameDelayNanos = - frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); - final double frameDelayInSeconds = frameDelayNanos / 1e9d; - - return new SentryFramesDelayResult(frameDelayInSeconds, frameMetrics.getSlowFrozenFrameCount()); - } - - /** - * Calculates frame metrics for a given time range by iterating over stored frames and handling - * partial overlaps at the boundaries. - * - * @param startNanos start of the time range - * @param endNanos end of the time range - * @param effectiveFrameDuration a single-element array that will be updated with the expected - * frame duration of the last iterated frame (used for pending delay / interpolation) - */ - private @NotNull SentryFrameMetrics calculateFrameMetrics( - final long startNanos, final long endNanos, final long @NotNull [] effectiveFrameDuration) { - final long durationNanos = endNanos - startNanos; - final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); - - if (!frames.isEmpty()) { - final Iterator iterator = frames.tailSet(new Frame(startNanos)).iterator(); - - //noinspection WhileLoopReplaceableByForEach - while (iterator.hasNext()) { - final @NotNull Frame frame = iterator.next(); - - if (frame.startNanos > endNanos) { - break; - } - - if (frame.startNanos >= startNanos && frame.endNanos <= endNanos) { - // if the frame is contained within the range, add it 1:1 - frameMetrics.addFrame( - frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); - } else if ((startNanos > frame.startNanos && startNanos < frame.endNanos) - || (endNanos > frame.startNanos && endNanos < frame.endNanos)) { - // range start or end are within frame — calculate the intersection - final long durationBeforeRange = Math.max(0, startNanos - frame.startNanos); - final long delayBeforeRange = - Math.max(0, durationBeforeRange - frame.expectedDurationNanos); - final long delayWithinRange = - Math.min(frame.delayNanos - delayBeforeRange, durationNanos); - - final long frameStart = Math.max(startNanos, frame.startNanos); - final long frameEnd = Math.min(endNanos, frame.endNanos); - final long frameDuration = frameEnd - frameStart; - frameMetrics.addFrame( - frameDuration, - delayWithinRange, - SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), - SentryFrameMetricsCollector.isFrozen(frameDuration)); - } - - effectiveFrameDuration[0] = frame.expectedDurationNanos; - } - } - - return frameMetrics; - } - @Override public void clear() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { 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/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index 181892fb992..e5d7349d37c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -443,207 +443,4 @@ class SpanFrameMetricsCollectorTest { assertEquals(567, a.diff(b)) } - - @Test - fun `getFramesDelay returns -1 when disabled`() { - val sut = fixture.getSut(enabled = false) - - val result = sut.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 sut = fixture.getSut() - - // when start >= end - val result = sut.getFramesDelay(2000, 1000) - assertEquals(-1.0, result.delaySeconds) - assertEquals(0, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay returns -1 for zero duration`() { - val sut = fixture.getSut() - - val result = sut.getFramesDelay(1000, 1000) - assertEquals(-1.0, result.delaySeconds) - assertEquals(0, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay returns zero delay when no frames recorded`() { - val sut = fixture.getSut() - - // given a span is running (so frames are being collected) - val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(2)) - sut.onSpanStarted(span) - - // choreographer reports a recent timestamp, so no pending delay - fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(1) - - // but no slow/frozen frames are recorded - val result = sut.getFramesDelay(0, TimeUnit.SECONDS.toNanos(1)) - assertEquals(0.0, result.delaySeconds) - assertEquals(0, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay calculates delay from slow and frozen frames`() { - val sut = fixture.getSut() - - // given a span is running (so frames are being collected) - val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(2)) - sut.onSpanStarted(span) - - // and one slow frame (32ms, 16ms delay) and one frozen frame (700ms, 684ms delay) - sut.onFrameMetricCollected(100, 132, 32, 16, true, false, 60.0f) - sut.onFrameMetricCollected(200, 900, 700, 684, true, true, 60.0f) - - // choreographer reports end of range, so no pending delay - fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(2) - - val result = sut.getFramesDelay(0, TimeUnit.SECONDS.toNanos(2)) - - // delay should be (16 + 684) nanos = 700ns = 0.0000007 seconds - assertEquals(700.0 / 1e9, result.delaySeconds, 0.0001) - assertEquals(2, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay handles partial frame overlap at start`() { - val sut = fixture.getSut() - - val ms = TimeUnit.MILLISECONDS - - // given a span is running - val span = createFakeSpan(0, ms.toNanos(5000)) - sut.onSpanStarted(span) - - // and a frozen frame: 0ms-1000ms (1s duration, delay=984ms) - sut.onFrameMetricCollected( - ms.toNanos(0), - ms.toNanos(1000), - ms.toNanos(1000), - ms.toNanos(984), - true, - true, - 60.0f, - ) - - // choreographer reports end of range - fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(2000) - - // query from 500ms to 2000ms — frame partially overlaps at start - val result = sut.getFramesDelay(ms.toNanos(500), ms.toNanos(2000)) - - // durationBeforeRange = 500ms, delayBeforeRange = max(0, 500ms - 16ms) = 484ms - // delayWithinRange = min(984ms - 484ms, 1500ms) = 500ms - // frameDuration = 1000ms - 500ms = 500ms, which is frozen (> 700ms? no, 500ms) - // but it IS slow since 500ms > 16ms expected - assertEquals(500.0 / 1e3, result.delaySeconds, 0.01) - assertEquals(1, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay handles partial frame overlap at end`() { - val sut = fixture.getSut() - - val ms = TimeUnit.MILLISECONDS - - // given a span is running - val span = createFakeSpan(0, ms.toNanos(5000)) - sut.onSpanStarted(span) - - // and a frozen frame: 500ms-1500ms (1s duration, delay=984ms) - sut.onFrameMetricCollected( - ms.toNanos(500), - ms.toNanos(1500), - ms.toNanos(1000), - ms.toNanos(984), - true, - true, - 60.0f, - ) - - // choreographer reports end of range - fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(800) - - // query from 0ms to 800ms — frame partially overlaps at end - val result = sut.getFramesDelay(ms.toNanos(0), ms.toNanos(800)) - - // frame starts at 500ms (>= 0), but end 1500ms > 800ms - // partial overlap: frameStart=500ms, frameEnd=800ms, frameDuration=300ms - // durationBeforeRange = 0, delayBeforeRange = 0 - // delayWithinRange = min(984ms, 800ms) = 800ms - // 300ms > 16ms expected, so isSlow=true - assertEquals(800.0 / 1e3, result.delaySeconds, 0.01) - assertEquals(1, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay includes pending frame delay`() { - val sut = fixture.getSut() - - // given a span is running - val span = createFakeSpan(0, TimeUnit.SECONDS.toNanos(3)) - sut.onSpanStarted(span) - - // and no frames are drawn, but the choreographer reports a timestamp - fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(1) - - // query from 1s to 2s — 1s since last choreographer frame = pending frozen frame - val result = sut.getFramesDelay(TimeUnit.SECONDS.toNanos(1), TimeUnit.SECONDS.toNanos(2)) - - // pending duration = 2s - 1s = 1s, which is frozen (> 700ms) - // pending delay = 1s - 16.67ms ≈ 983.33ms - assertEquals(0.983333, result.delaySeconds, 0.01) - assertEquals(1, result.framesContributingToDelayCount) - } - - @Test - fun `getFramesDelay does not require an active span for querying`() { - val sut = fixture.getSut() - - val ms = TimeUnit.MILLISECONDS - - // given a span is running (to trigger frame collection) - val span = createFakeSpan(0, ms.toNanos(2000)) - sut.onSpanStarted(span) - - // and frames are recorded - sut.onFrameMetricCollected( - ms.toNanos(10), - ms.toNanos(42), - ms.toNanos(32), - ms.toNanos(16), - true, - false, - 60.0f, - ) - sut.onFrameMetricCollected( - ms.toNanos(50), - ms.toNanos(750), - ms.toNanos(700), - ms.toNanos(684), - true, - true, - 60.0f, - ) - - // choreographer reports end of query range - fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(1000) - - // getFramesDelay can be called independently without finishing the span - val result = sut.getFramesDelay(0, ms.toNanos(1000)) - assertEquals(700.0 / 1e3, result.delaySeconds, 0.001) - assertEquals(2, result.framesContributingToDelayCount) - - // the span is still running and can still finish normally - fixture.lastKnownChoreographerFrameTimeNanos = ms.toNanos(2000) - sut.onSpanFinished(span) - verify(span).setData("frames.slow", 1) - verify(span).setData("frames.frozen", 1) - } } 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()