Skip to content

Commit f3c7863

Browse files
antonisclaude
andauthored
feat(core): Add frames.delay data from native SDKs (#5907)
* feat(core): Add frames.delay data from native SDKs Fetch frames.delay from native (iOS via getFramesDelaySPI, Android via SentryFrameMetricsCollector listener) and attach it to app start spans, TTID/TTFD spans, and all JS API-started spans. Closes #4869 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: Add changelog entry for frames.delay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): Guard against nil SPI result on iOS and leaked listener on Android - iOS: Add nil check on getFramesDelaySPI result before accessing delayDuration (messaging nil returns 0 in ObjC, causing false frames.delay: 0) - Android: Call stop() before start() in RNSentryFrameDelayCollector to prevent leaked listeners on repeated initialization (e.g. JS bundle reload) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tracing): Use actual span end timestamp for frames.delay in TTID/TTFD Pass the intended span end timestamp into captureEndFramesAndAttachToSpan instead of falling back to Date.now(). The function runs before span.end() is called, so spanToJSON(span).timestamp is always undefined, causing the delay calculation to include frame data after the span semantically ended. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tracing): Add timeout protection for fetchNativeFramesDelay in TTID/TTFD and app start Wrap NATIVE.fetchNativeFramesDelay calls in timetodisplay.tsx and appStart.ts with Promise.race timeout (2s), matching the timeout protection already present in nativeFrames.ts. Prevents indefinite blocking if the native bridge hangs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tracing): Only fetch frames.delay when frame count data is available Gate frames.delay fetch on appStartEndData.endFrames being present, matching the native SDK behavior where both frame counts and delay are gated on the frames tracker running. Prevents attaching frames.delay to spans that have no frames.total/slow/frozen data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ios): Move frames delay logic to SentryScreenFramesWrapper to fix build The SPI types (SentryFramesTracker, SentryCurrentDateProvider, SentryFramesDelayResultSPI) are only accessible via `@import Sentry;` which SentryScreenFramesWrapper.m already uses. RNSentry.mm only imports individual headers and cannot see these Swift SPI types. Move the frames delay computation into a new +framesDelayForStartTimestamp:endTimestamp: method on SentryScreenFramesWrapper and call it from RNSentry.mm. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix lint issues * fix(tracing): Gate frames.delay on non-zero frame counts in nativeFramesIntegration Move frames.delay fetch inside the `if (totalFrames > 0 || ...)` guard so it's only attached when frame count data is also present. Prevents spans from having frames.delay without frames.total. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d133d66 commit f3c7863

File tree

17 files changed

+546
-13
lines changed

17 files changed

+546
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Features
1212

13+
- Add `frames.delay` span data from native SDKs to app start, TTID/TTFD, and JS API spans ([#5907](https://github.com/getsentry/sentry-react-native/pull/5907))
1314
- Rename `FeedbackWidget` to `FeedbackForm` and `showFeedbackWidget` to `showFeedbackForm` ([#5931](https://github.com/getsentry/sentry-react-native/pull/5931))
1415
- The old names are deprecated but still work
1516
- Deprecate `FeedbackButton`, `showFeedbackButton`, and `hideFeedbackButton` ([#5933](https://github.com/getsentry/sentry-react-native/pull/5933))
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package io.sentry.react;
2+
3+
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
4+
import java.util.List;
5+
import java.util.concurrent.CopyOnWriteArrayList;
6+
import org.jetbrains.annotations.Nullable;
7+
8+
/**
9+
* Collects per-frame delay data from {@link SentryFrameMetricsCollector} and provides a method to
10+
* query the accumulated delay within a given time range.
11+
*
12+
* <p>This is a temporary solution until sentry-java exposes a queryable API for frames delay
13+
* (similar to sentry-cocoa's getFramesDelaySPI).
14+
*/
15+
public class RNSentryFrameDelayCollector
16+
implements SentryFrameMetricsCollector.FrameMetricsCollectorListener {
17+
18+
private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes
19+
20+
private final List<FrameRecord> frames = new CopyOnWriteArrayList<>();
21+
22+
private @Nullable String listenerId;
23+
private @Nullable SentryFrameMetricsCollector collector;
24+
25+
/**
26+
* Starts collecting frame delay data from the given collector.
27+
*
28+
* @return true if collection was started successfully
29+
*/
30+
public boolean start(@Nullable SentryFrameMetricsCollector frameMetricsCollector) {
31+
if (frameMetricsCollector == null) {
32+
return false;
33+
}
34+
stop();
35+
this.collector = frameMetricsCollector;
36+
this.listenerId = frameMetricsCollector.startCollection(this);
37+
return this.listenerId != null;
38+
}
39+
40+
/** Stops collecting frame delay data. */
41+
public void stop() {
42+
if (collector != null && listenerId != null) {
43+
collector.stopCollection(listenerId);
44+
listenerId = null;
45+
collector = null;
46+
}
47+
frames.clear();
48+
}
49+
50+
@Override
51+
public void onFrameMetricCollected(
52+
long frameStartNanos,
53+
long frameEndNanos,
54+
long durationNanos,
55+
long delayNanos,
56+
boolean isSlow,
57+
boolean isFrozen,
58+
float refreshRate) {
59+
if (delayNanos <= 0) {
60+
return;
61+
}
62+
frames.add(new FrameRecord(frameStartNanos, frameEndNanos, delayNanos));
63+
pruneOldFrames(frameEndNanos);
64+
}
65+
66+
/**
67+
* Returns the total frames delay in seconds for the given time range.
68+
*
69+
* <p>Handles partial overlap: if a frame's delay period partially falls within the query range,
70+
* only the overlapping portion is counted.
71+
*
72+
* @param startNanos start of the query range in system nanos (e.g., System.nanoTime())
73+
* @param endNanos end of the query range in system nanos
74+
* @return delay in seconds, or -1 if no data is available
75+
*/
76+
public double getFramesDelay(long startNanos, long endNanos) {
77+
if (startNanos >= endNanos) {
78+
return -1;
79+
}
80+
81+
long totalDelayNanos = 0;
82+
83+
for (FrameRecord frame : frames) {
84+
if (frame.endNanos <= startNanos) {
85+
continue;
86+
}
87+
if (frame.startNanos >= endNanos) {
88+
break;
89+
}
90+
91+
// The delay portion of a frame is at the end of the frame duration.
92+
// delayStart = frameEnd - delay, delayEnd = frameEnd
93+
long delayStart = frame.endNanos - frame.delayNanos;
94+
long delayEnd = frame.endNanos;
95+
96+
// Intersect the delay interval with the query range
97+
long overlapStart = Math.max(delayStart, startNanos);
98+
long overlapEnd = Math.min(delayEnd, endNanos);
99+
100+
if (overlapEnd > overlapStart) {
101+
totalDelayNanos += (overlapEnd - overlapStart);
102+
}
103+
}
104+
105+
return totalDelayNanos / 1e9;
106+
}
107+
108+
private void pruneOldFrames(long currentNanos) {
109+
long cutoff = currentNanos - MAX_FRAME_AGE_NANOS;
110+
// Remove from the front one-by-one. CopyOnWriteArrayList.remove(0) is O(n) per call,
111+
// but old frames are pruned incrementally so typically only 0-1 entries are removed.
112+
while (!frames.isEmpty() && frames.get(0).endNanos < cutoff) {
113+
frames.remove(0);
114+
}
115+
}
116+
117+
private static class FrameRecord {
118+
final long startNanos;
119+
final long endNanos;
120+
final long delayNanos;
121+
122+
FrameRecord(long startNanos, long endNanos, long delayNanos) {
123+
this.startNanos = startNanos;
124+
this.endNanos = endNanos;
125+
this.delayNanos = delayNanos;
126+
}
127+
}
128+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public class RNSentryModuleImpl {
9494
private final ReactApplicationContext reactApplicationContext;
9595
private final PackageInfo packageInfo;
9696
private FrameMetricsAggregator frameMetricsAggregator = null;
97+
private final RNSentryFrameDelayCollector frameDelayCollector = new RNSentryFrameDelayCollector();
9798
private boolean androidXAvailable;
9899

99100
@VisibleForTesting static long lastStartTimestampMs = -1;
@@ -379,6 +380,39 @@ public void fetchNativeFrames(Promise promise) {
379380
}
380381
}
381382

383+
public void fetchNativeFramesDelay(
384+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
385+
try {
386+
// Convert wall-clock seconds to System.nanoTime() based nanos
387+
long nowNanos = System.nanoTime();
388+
double nowSeconds = System.currentTimeMillis() / 1e3;
389+
390+
double startOffsetSeconds = nowSeconds - startTimestampSeconds;
391+
double endOffsetSeconds = nowSeconds - endTimestampSeconds;
392+
393+
if (startOffsetSeconds < 0
394+
|| endOffsetSeconds < 0
395+
|| (long) (startOffsetSeconds * 1e9) > nowNanos
396+
|| (long) (endOffsetSeconds * 1e9) > nowNanos) {
397+
promise.resolve(null);
398+
return;
399+
}
400+
401+
long startNanos = nowNanos - (long) (startOffsetSeconds * 1e9);
402+
long endNanos = nowNanos - (long) (endOffsetSeconds * 1e9);
403+
404+
double delaySeconds = frameDelayCollector.getFramesDelay(startNanos, endNanos);
405+
if (delaySeconds >= 0) {
406+
promise.resolve(delaySeconds);
407+
} else {
408+
promise.resolve(null);
409+
}
410+
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
411+
logger.log(SentryLevel.WARNING, "Error fetching native frames delay.");
412+
promise.resolve(null);
413+
}
414+
}
415+
382416
public void captureReplay(boolean isHardCrash, Promise promise) {
383417
Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash);
384418
promise.resolve(getCurrentReplayId());
@@ -693,13 +727,27 @@ public void enableNativeFramesTracking() {
693727
} else {
694728
logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency.");
695729
}
730+
731+
try {
732+
final SentryOptions options = Sentry.getCurrentScopes().getOptions();
733+
if (options instanceof SentryAndroidOptions) {
734+
final SentryFrameMetricsCollector collector =
735+
((SentryAndroidOptions) options).getFrameMetricsCollector();
736+
if (frameDelayCollector.start(collector)) {
737+
logger.log(SentryLevel.INFO, "RNSentryFrameDelayCollector installed.");
738+
}
739+
}
740+
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
741+
logger.log(SentryLevel.WARNING, "Error starting RNSentryFrameDelayCollector.");
742+
}
696743
}
697744

698745
public void disableNativeFramesTracking() {
699746
if (isFrameMetricsAggregatorAvailable()) {
700747
frameMetricsAggregator.stop();
701748
frameMetricsAggregator = null;
702749
}
750+
frameDelayCollector.stop();
703751
}
704752

705753
public void getNewScreenTimeToDisplay(Promise promise) {

packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
6767
this.impl.fetchNativeFrames(promise);
6868
}
6969

70+
@Override
71+
public void fetchNativeFramesDelay(
72+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
73+
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
74+
}
75+
7076
@Override
7177
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
7278
this.impl.captureEnvelope(rawBytes, options, promise);

packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
6767
this.impl.fetchNativeFrames(promise);
6868
}
6969

70+
@ReactMethod
71+
public void fetchNativeFramesDelay(
72+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
73+
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
74+
}
75+
7076
@ReactMethod
7177
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
7278
this.impl.captureEnvelope(rawBytes, options, promise);

packages/core/ios/RNSentry.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,23 @@ - (void)handleShakeDetected
539539
#endif
540540
}
541541

542+
RCT_EXPORT_METHOD(fetchNativeFramesDelay : (double)startTimestampSeconds endTimestampSeconds : (
543+
double)endTimestampSeconds resolve : (RCTPromiseResolveBlock)
544+
resolve rejecter : (RCTPromiseRejectBlock)reject)
545+
{
546+
#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
547+
if (![SentryScreenFramesWrapper canTrackFrames]) {
548+
resolve(nil);
549+
return;
550+
}
551+
552+
resolve([SentryScreenFramesWrapper framesDelayForStartTimestamp:startTimestampSeconds
553+
endTimestamp:endTimestampSeconds]);
554+
#else
555+
resolve(nil);
556+
#endif
557+
}
558+
542559
RCT_EXPORT_METHOD(
543560
fetchNativeRelease : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
544561
{

packages/core/ios/SentryScreenFramesWrapper.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
+ (NSNumber *)totalFrames;
99
+ (NSNumber *)frozenFrames;
1010
+ (NSNumber *)slowFrames;
11+
+ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds
12+
endTimestamp:(double)endTimestampSeconds;
1113

1214
@end
1315

packages/core/ios/SentryScreenFramesWrapper.m

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,41 @@ + (NSNumber *)slowFrames
3434
return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.slow];
3535
}
3636

37+
+ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds
38+
endTimestamp:(double)endTimestampSeconds
39+
{
40+
SentryFramesTracker *framesTracker = [[SentryDependencyContainer sharedInstance] framesTracker];
41+
42+
if (!framesTracker.isRunning) {
43+
return nil;
44+
}
45+
46+
id<SentryCurrentDateProvider> dateProvider =
47+
[SentryDependencyContainer sharedInstance].dateProvider;
48+
uint64_t currentSystemTime = [dateProvider systemTime];
49+
NSTimeInterval currentWallClock = [[dateProvider date] timeIntervalSince1970];
50+
51+
double startOffsetSeconds = currentWallClock - startTimestampSeconds;
52+
double endOffsetSeconds = currentWallClock - endTimestampSeconds;
53+
54+
if (startOffsetSeconds < 0 || endOffsetSeconds < 0
55+
|| (uint64_t)(startOffsetSeconds * 1e9) > currentSystemTime
56+
|| (uint64_t)(endOffsetSeconds * 1e9) > currentSystemTime) {
57+
return nil;
58+
}
59+
60+
uint64_t startSystemTime = currentSystemTime - (uint64_t)(startOffsetSeconds * 1e9);
61+
uint64_t endSystemTime = currentSystemTime - (uint64_t)(endOffsetSeconds * 1e9);
62+
63+
SentryFramesDelayResultSPI *result = [framesTracker getFramesDelaySPI:startSystemTime
64+
endSystemTimestamp:endSystemTime];
65+
66+
if (result != nil && result.delayDuration >= 0) {
67+
return @(result.delayDuration);
68+
}
69+
return nil;
70+
}
71+
3772
@end
3873

3974
#endif // TARGET_OS_IPHONE || TARGET_OS_MACCATALYST

packages/core/src/js/NativeRNSentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Spec extends TurboModule {
2929
fetchNativeLogAttributes(): Promise<NativeDeviceContextsResponse | null>;
3030
fetchNativeAppStart(): Promise<NativeAppStartResponse | null>;
3131
fetchNativeFrames(): Promise<NativeFramesResponse | null>;
32+
fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise<number | null>;
3233
initNativeSdk(options: UnsafeObject): Promise<boolean>;
3334
setUser(defaultUserKeys: UnsafeObject | null, otherUserKeys: UnsafeObject | null): void;
3435
setContext(key: string, value: UnsafeObject | null): void;

packages/core/src/js/tracing/integrations/appStart.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export function _clearRootComponentCreationTimestampMs(): void {
164164
* Attaches frame data to a span's data object.
165165
*/
166166
function attachFrameDataToSpan(span: SpanJSON, frames: NativeFramesResponse): void {
167-
if (frames.totalFrames <= 0 && frames.slowFrames <= 0 && frames.totalFrames <= 0) {
167+
if (frames.totalFrames <= 0 && frames.slowFrames <= 0 && frames.frozenFrames <= 0) {
168168
debug.warn(`[AppStart] Detected zero slow or frozen frames. Not adding measurements to spanId (${span.span_id}).`);
169169
return;
170170
}
@@ -501,6 +501,19 @@ export const appStartIntegration = ({
501501

502502
if (appStartEndData?.endFrames) {
503503
attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames);
504+
505+
try {
506+
const framesDelay = await Promise.race([
507+
NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds),
508+
new Promise<null>(resolve => setTimeout(() => resolve(null), 2_000)),
509+
]);
510+
if (framesDelay != null) {
511+
appStartSpanJSON.data = appStartSpanJSON.data || {};
512+
appStartSpanJSON.data['frames.delay'] = framesDelay;
513+
}
514+
} catch (error) {
515+
debug.log('[AppStart] Error while fetching frames delay for app start span.', error);
516+
}
504517
}
505518

506519
const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs);

0 commit comments

Comments
 (0)