Skip to content

Commit 44e21df

Browse files
antonisclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 5865051 commit 44e21df

File tree

6 files changed

+361
-47
lines changed

6 files changed

+361
-47
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
369369
public fun getNativeSdkName ()Ljava/lang/String;
370370
public fun getNdkHandlerStrategy ()I
371371
public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions;
372+
public fun getSpanFrameMetricsCollector ()Lio/sentry/android/core/SpanFrameMetricsCollector;
372373
public fun getStartupCrashDurationThresholdMillis ()J
373374
public fun isAnrEnabled ()Z
374375
public fun isAnrProfilingEnabled ()Z
@@ -428,13 +429,20 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
428429
public fun setNativeSdkName (Ljava/lang/String;)V
429430
public fun setReportHistoricalAnrs (Z)V
430431
public fun setReportHistoricalTombstones (Z)V
432+
public fun setSpanFrameMetricsCollector (Lio/sentry/android/core/SpanFrameMetricsCollector;)V
431433
public fun setTombstoneEnabled (Z)V
432434
}
433435

434436
public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback {
435437
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z
436438
}
437439

440+
public final class io/sentry/android/core/SentryFramesDelayResult {
441+
public fun <init> (DI)V
442+
public fun getDelaySeconds ()D
443+
public fun getFramesContributingToDelayCount ()I
444+
}
445+
438446
public final class io/sentry/android/core/SentryInitProvider {
439447
public fun <init> ()V
440448
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
520528
protected final field lock Lio/sentry/util/AutoClosableReentrantLock;
521529
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
522530
public fun clear ()V
531+
public fun getFramesDelay (JJ)Lio/sentry/android/core/SentryFramesDelayResult;
523532
public fun onFrameMetricCollected (JJJJZZF)V
524533
public fun onSpanFinished (Lio/sentry/ISpan;)V
525534
public fun onSpanStarted (Lio/sentry/ISpan;)V

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,14 @@ static void initializeIntegrationsAndProcessors(
251251
options.addPerformanceCollector(new AndroidCpuCollector(options.getLogger()));
252252

253253
if (options.isEnablePerformanceV2()) {
254-
options.addPerformanceCollector(
254+
final SpanFrameMetricsCollector spanFrameMetricsCollector =
255255
new SpanFrameMetricsCollector(
256256
options,
257257
Objects.requireNonNull(
258258
options.getFrameMetricsCollector(),
259-
"options.getFrameMetricsCollector is required")));
259+
"options.getFrameMetricsCollector is required"));
260+
options.addPerformanceCollector(spanFrameMetricsCollector);
261+
options.setSpanFrameMetricsCollector(spanFrameMetricsCollector);
260262
}
261263
}
262264
if (options.getCompositePerformanceCollector() instanceof NoOpCompositePerformanceCollector) {

sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ public interface BeforeCaptureCallback {
242242

243243
private @Nullable SentryFrameMetricsCollector frameMetricsCollector;
244244

245+
private @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector;
246+
245247
private boolean enableTombstone = false;
246248

247249
/**
@@ -674,6 +676,17 @@ public void setFrameMetricsCollector(
674676
this.frameMetricsCollector = frameMetricsCollector;
675677
}
676678

679+
@ApiStatus.Internal
680+
public @Nullable SpanFrameMetricsCollector getSpanFrameMetricsCollector() {
681+
return spanFrameMetricsCollector;
682+
}
683+
684+
@ApiStatus.Internal
685+
public void setSpanFrameMetricsCollector(
686+
final @Nullable SpanFrameMetricsCollector spanFrameMetricsCollector) {
687+
this.spanFrameMetricsCollector = spanFrameMetricsCollector;
688+
}
689+
677690
public boolean isEnableAutoTraceIdGeneration() {
678691
return enableAutoTraceIdGeneration;
679692
}
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/SpanFrameMetricsCollector.java

Lines changed: 101 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -152,49 +152,11 @@ private void captureFrameMetrics(@NotNull final ISpan span) {
152152
return;
153153
}
154154

155-
final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics();
156-
157-
long frameDurationNanos = lastKnownFrameDurationNanos;
158-
159-
if (!frames.isEmpty()) {
160-
// determine relevant start in frames list
161-
final Iterator<Frame> iterator = frames.tailSet(new Frame(spanStartNanos)).iterator();
162-
163-
//noinspection WhileLoopReplaceableByForEach
164-
while (iterator.hasNext()) {
165-
final @NotNull Frame frame = iterator.next();
166-
167-
if (frame.startNanos > spanEndNanos) {
168-
break;
169-
}
170-
171-
if (frame.startNanos >= spanStartNanos && frame.endNanos <= spanEndNanos) {
172-
// if the frame is contained within the span, add it 1:1 to the span metrics
173-
frameMetrics.addFrame(
174-
frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen);
175-
} else if ((spanStartNanos > frame.startNanos && spanStartNanos < frame.endNanos)
176-
|| (spanEndNanos > frame.startNanos && spanEndNanos < frame.endNanos)) {
177-
// span start or end are within frame
178-
// calculate the intersection
179-
final long durationBeforeSpan = Math.max(0, spanStartNanos - frame.startNanos);
180-
final long delayBeforeSpan =
181-
Math.max(0, durationBeforeSpan - frame.expectedDurationNanos);
182-
final long delayWithinSpan =
183-
Math.min(frame.delayNanos - delayBeforeSpan, spanDurationNanos);
184-
185-
final long frameStart = Math.max(spanStartNanos, frame.startNanos);
186-
final long frameEnd = Math.min(spanEndNanos, frame.endNanos);
187-
final long frameDuration = frameEnd - frameStart;
188-
frameMetrics.addFrame(
189-
frameDuration,
190-
delayWithinSpan,
191-
SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos),
192-
SentryFrameMetricsCollector.isFrozen(frameDuration));
193-
}
194-
195-
frameDurationNanos = frame.expectedDurationNanos;
196-
}
197-
}
155+
// effectiveFrameDuration tracks the expected frame duration of the last frame
156+
// iterated within the span's time range, falling back to lastKnownFrameDurationNanos
157+
final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos};
158+
final @NotNull SentryFrameMetrics frameMetrics =
159+
calculateFrameMetrics(spanStartNanos, spanEndNanos, effectiveFrameDuration);
198160

199161
int totalFrameCount = frameMetrics.getSlowFrozenFrameCount();
200162

@@ -204,9 +166,9 @@ private void captureFrameMetrics(@NotNull final ISpan span) {
204166
if (nextScheduledFrameNanos != -1) {
205167
totalFrameCount +=
206168
addPendingFrameDelay(
207-
frameMetrics, frameDurationNanos, spanEndNanos, nextScheduledFrameNanos);
169+
frameMetrics, effectiveFrameDuration[0], spanEndNanos, nextScheduledFrameNanos);
208170
totalFrameCount +=
209-
interpolateFrameCount(frameMetrics, frameDurationNanos, spanDurationNanos);
171+
interpolateFrameCount(frameMetrics, effectiveFrameDuration[0], spanDurationNanos);
210172
}
211173
final long frameDelayNanos =
212174
frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos();
@@ -226,6 +188,100 @@ private void captureFrameMetrics(@NotNull final ISpan span) {
226188
}
227189
}
228190

191+
/**
192+
* Queries the frame delay for a given time range, without requiring an active span.
193+
*
194+
* <p>This is useful for external consumers (e.g. React Native SDK) that need to query frame delay
195+
* for an arbitrary time range without registering their own frame listener.
196+
*
197+
* @param startSystemNanos start of the time range in {@link System#nanoTime()} units
198+
* @param endSystemNanos end of the time range in {@link System#nanoTime()} units
199+
* @return a {@link SentryFramesDelayResult} with the delay in seconds and the number of frames
200+
* contributing to delay, or a result with delaySeconds=-1 if incalculable
201+
*/
202+
public @NotNull SentryFramesDelayResult getFramesDelay(
203+
final long startSystemNanos, final long endSystemNanos) {
204+
if (!enabled) {
205+
return new SentryFramesDelayResult(-1, 0);
206+
}
207+
208+
final long durationNanos = endSystemNanos - startSystemNanos;
209+
if (durationNanos <= 0) {
210+
return new SentryFramesDelayResult(-1, 0);
211+
}
212+
213+
final long[] effectiveFrameDuration = {lastKnownFrameDurationNanos};
214+
final @NotNull SentryFrameMetrics frameMetrics =
215+
calculateFrameMetrics(startSystemNanos, endSystemNanos, effectiveFrameDuration);
216+
217+
final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos();
218+
if (nextScheduledFrameNanos != -1) {
219+
addPendingFrameDelay(
220+
frameMetrics, effectiveFrameDuration[0], endSystemNanos, nextScheduledFrameNanos);
221+
}
222+
223+
final long frameDelayNanos =
224+
frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos();
225+
final double frameDelayInSeconds = frameDelayNanos / 1e9d;
226+
227+
return new SentryFramesDelayResult(frameDelayInSeconds, frameMetrics.getSlowFrozenFrameCount());
228+
}
229+
230+
/**
231+
* Calculates frame metrics for a given time range by iterating over stored frames and handling
232+
* partial overlaps at the boundaries.
233+
*
234+
* @param startNanos start of the time range
235+
* @param endNanos end of the time range
236+
* @param effectiveFrameDuration a single-element array that will be updated with the expected
237+
* frame duration of the last iterated frame (used for pending delay / interpolation)
238+
*/
239+
private @NotNull SentryFrameMetrics calculateFrameMetrics(
240+
final long startNanos, final long endNanos, final long @NotNull [] effectiveFrameDuration) {
241+
final long durationNanos = endNanos - startNanos;
242+
final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics();
243+
244+
if (!frames.isEmpty()) {
245+
final Iterator<Frame> iterator = frames.tailSet(new Frame(startNanos)).iterator();
246+
247+
//noinspection WhileLoopReplaceableByForEach
248+
while (iterator.hasNext()) {
249+
final @NotNull Frame frame = iterator.next();
250+
251+
if (frame.startNanos > endNanos) {
252+
break;
253+
}
254+
255+
if (frame.startNanos >= startNanos && frame.endNanos <= endNanos) {
256+
// if the frame is contained within the range, add it 1:1
257+
frameMetrics.addFrame(
258+
frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen);
259+
} else if ((startNanos > frame.startNanos && startNanos < frame.endNanos)
260+
|| (endNanos > frame.startNanos && endNanos < frame.endNanos)) {
261+
// range start or end are within frame — calculate the intersection
262+
final long durationBeforeRange = Math.max(0, startNanos - frame.startNanos);
263+
final long delayBeforeRange =
264+
Math.max(0, durationBeforeRange - frame.expectedDurationNanos);
265+
final long delayWithinRange =
266+
Math.min(frame.delayNanos - delayBeforeRange, durationNanos);
267+
268+
final long frameStart = Math.max(startNanos, frame.startNanos);
269+
final long frameEnd = Math.min(endNanos, frame.endNanos);
270+
final long frameDuration = frameEnd - frameStart;
271+
frameMetrics.addFrame(
272+
frameDuration,
273+
delayWithinRange,
274+
SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos),
275+
SentryFrameMetricsCollector.isFrozen(frameDuration));
276+
}
277+
278+
effectiveFrameDuration[0] = frame.expectedDurationNanos;
279+
}
280+
}
281+
282+
return frameMetrics;
283+
}
284+
229285
@Override
230286
public void clear() {
231287
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {

0 commit comments

Comments
 (0)