Skip to content

Commit c639edf

Browse files
Fix(V6): Implement fallback system to screens that aren't reporting on the native layer the time to display. #4042 (#4189)
* merge main * fix tests / yarn fix * changelog * undoo changes to sample * fix android * NIT * NIT extra line * fix-android-lint * fix java format * pub private constructor * make it final * test * add missing dependencies changelog
1 parent 4c3c7db commit c639edf

17 files changed

+510
-57
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
99
## Unreleased
1010

11+
### Fixes
12+
13+
- Enhanced accuracy of time-to-display spans. ([#4189](https://github.com/getsentry/sentry-react-native/pull/4189))
14+
1115
### Dependencies
1216

1317
- Bump JavaScript SDK from v8.34.0 to v8.35.0 ([#4196](https://github.com/getsentry/sentry-react-native/pull/4196))

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,13 @@ public class RNSentryModuleImpl {
126126
/** Max trace file size in bytes. */
127127
private long maxTraceFileSize = 5 * 1024 * 1024;
128128

129+
private final @NotNull SentryDateProvider dateProvider;
130+
129131
public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) {
130132
packageInfo = getPackageInfo(reactApplicationContext);
131133
this.reactApplicationContext = reactApplicationContext;
132134
this.emitNewFrameEvent = createEmitNewFrameEvent();
135+
this.dateProvider = new SentryAndroidDateProvider();
133136
}
134137

135138
private ReactApplicationContext getReactApplicationContext() {
@@ -141,8 +144,6 @@ private ReactApplicationContext getReactApplicationContext() {
141144
}
142145

143146
private @NotNull Runnable createEmitNewFrameEvent() {
144-
final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider();
145-
146147
return () -> {
147148
final SentryDate endDate = dateProvider.now();
148149
WritableMap event = Arguments.createMap();
@@ -745,6 +746,10 @@ public void disableNativeFramesTracking() {
745746
}
746747
}
747748

749+
public void getNewScreenTimeToDisplay(Promise promise) {
750+
RNSentryTimeToDisplay.getTimeToDisplay(promise, dateProvider);
751+
}
752+
748753
private String getProfilingTracesDirPath() {
749754
if (cacheDirPath == null) {
750755
cacheDirPath =
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.sentry.react;
2+
3+
import android.os.Handler;
4+
import android.os.Looper;
5+
import android.view.Choreographer;
6+
import com.facebook.react.bridge.Promise;
7+
import io.sentry.SentryDate;
8+
import io.sentry.SentryDateProvider;
9+
10+
public final class RNSentryTimeToDisplay {
11+
12+
private RNSentryTimeToDisplay() {}
13+
14+
public static void getTimeToDisplay(Promise promise, SentryDateProvider dateProvider) {
15+
Looper mainLooper = Looper.getMainLooper();
16+
17+
if (mainLooper == null) {
18+
promise.reject(
19+
"GetTimeToDisplay is not able to measure the time to display: Main looper not"
20+
+ " available.");
21+
return;
22+
}
23+
24+
// Ensure the code runs on the main thread
25+
new Handler(mainLooper)
26+
.post(
27+
() -> {
28+
try {
29+
Choreographer choreographer = Choreographer.getInstance();
30+
31+
// Invoke the callback after the frame is rendered
32+
choreographer.postFrameCallback(
33+
frameTimeNanos -> {
34+
final SentryDate endDate = dateProvider.now();
35+
promise.resolve(endDate.nanoTimestamp() / 1e9);
36+
});
37+
} catch (Exception exception) {
38+
promise.reject("Failed to receive the instance of Choreographer", exception);
39+
}
40+
});
41+
}
42+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,9 @@ public String getCurrentReplayId() {
172172
public void crashedLastRun(Promise promise) {
173173
this.impl.crashedLastRun(promise);
174174
}
175+
176+
@Override
177+
public void getNewScreenTimeToDisplay(Promise promise) {
178+
this.impl.getNewScreenTimeToDisplay(promise);
179+
}
175180
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,9 @@ public String getCurrentReplayId() {
172172
public void crashedLastRun(Promise promise) {
173173
this.impl.crashedLastRun(promise);
174174
}
175+
176+
@ReactMethod()
177+
public void getNewScreenTimeToDisplay(Promise promise) {
178+
this.impl.getNewScreenTimeToDisplay(promise);
179+
}
175180
}

packages/core/ios/RNSentry.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#import <dlfcn.h>
22
#import "RNSentry.h"
3+
#import "RNSentryTimeToDisplay.h"
34

45
#if __has_include(<React/RCTConvert.h>)
56
#import <React/RCTConvert.h>
@@ -62,6 +63,7 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope;
6263
@implementation RNSentry {
6364
bool sentHybridSdkDidBecomeActive;
6465
bool hasListeners;
66+
RNSentryTimeToDisplay *_timeToDisplay;
6567
}
6668

6769
- (dispatch_queue_t)methodQueue
@@ -139,6 +141,8 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)
139141
[mutableOptions removeObjectForKey:@"tracesSampler"];
140142
[mutableOptions removeObjectForKey:@"enableTracing"];
141143

144+
_timeToDisplay = [[RNSentryTimeToDisplay alloc] init];
145+
142146
#if SENTRY_TARGET_REPLAY_SUPPORTED
143147
[RNSentryReplay updateOptions:mutableOptions];
144148
#endif
@@ -786,4 +790,9 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray<NSNumber*>*)instructionsAdd
786790
}
787791
#endif
788792

793+
RCT_EXPORT_METHOD(getNewScreenTimeToDisplay:(RCTPromiseResolveBlock)resolve
794+
rejecter:(RCTPromiseRejectBlock)reject) {
795+
[_timeToDisplay getTimeToDisplay:resolve];
796+
}
797+
789798
@end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
@interface RNSentryTimeToDisplay : NSObject
4+
5+
- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback;
6+
7+
@end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#import "RNSentryTimeToDisplay.h"
2+
#import <QuartzCore/QuartzCore.h>
3+
#import <React/RCTLog.h>
4+
5+
@implementation RNSentryTimeToDisplay
6+
{
7+
CADisplayLink *displayLink;
8+
RCTResponseSenderBlock resolveBlock;
9+
}
10+
11+
// Rename requestAnimationFrame to getTimeToDisplay
12+
- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback
13+
{
14+
// Store the resolve block to use in the callback.
15+
resolveBlock = callback;
16+
17+
#if TARGET_OS_IOS
18+
// Create and add a display link to get the callback after the screen is rendered.
19+
displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
20+
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
21+
#else
22+
resolveBlock(@[]); // Return nothing if not iOS.
23+
#endif
24+
}
25+
26+
#if TARGET_OS_IOS
27+
- (void)handleDisplayLink:(CADisplayLink *)link {
28+
// Get the current time
29+
NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970] * 1000.0; // Convert to milliseconds
30+
31+
// Ensure the callback is valid and pass the current time back
32+
if (resolveBlock) {
33+
resolveBlock(@[@(currentTime)]); // Call the callback with the current time
34+
resolveBlock = nil; // Clear the block after it's called
35+
}
36+
37+
// Invalidate the display link to stop future callbacks
38+
[displayLink invalidate];
39+
displayLink = nil;
40+
}
41+
#endif
42+
43+
@end

packages/core/src/js/NativeRNSentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { UnsafeObject } from './utils/rnlibrariesinterface';
99
export interface Spec extends TurboModule {
1010
addListener: (eventType: string) => void;
1111
removeListeners: (id: number) => void;
12+
getNewScreenTimeToDisplay(): Promise<number | undefined | null>;
1213
addBreadcrumb(breadcrumb: UnsafeObject): void;
1314
captureEnvelope(
1415
bytes: string,

packages/core/src/js/tracing/reactnavigation.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
import type { Client, Integration, Span } from '@sentry/types';
1313
import { isPlainObject, logger, timestampInSeconds } from '@sentry/utils';
1414

15-
import type { NewFrameEvent, SentryEventEmitter } from '../utils/sentryeventemitter';
16-
import { createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter';
15+
import type { NewFrameEvent } from '../utils/sentryeventemitter';
16+
import type { SentryEventEmitterFallback } from '../utils/sentryeventemitterfallback';
17+
import { createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback';
1718
import { isSentrySpan } from '../utils/span';
1819
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
1920
import { NATIVE } from '../wrapper';
@@ -30,7 +31,6 @@ import {
3031
} from './span';
3132
import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay';
3233
import { setSpanDurationAsMeasurementOnSpan } from './utils';
33-
3434
export const INTEGRATION_NAME = 'ReactNavigation';
3535

3636
const NAVIGATION_HISTORY_MAX_SIZE = 200;
@@ -81,7 +81,7 @@ export const reactNavigationIntegration = ({
8181
registerNavigationContainer: (navigationContainerRef: unknown) => void;
8282
} => {
8383
let navigationContainer: NavigationContainer | undefined;
84-
let newScreenFrameEventEmitter: SentryEventEmitter | undefined;
84+
let newScreenFrameEventEmitter: SentryEventEmitterFallback | undefined;
8585

8686
let tracing: ReactNativeTracingIntegration | undefined;
8787
let idleSpanOptions: Parameters<typeof startGenericIdleNavigationSpan>[1] = defaultIdleOptions;
@@ -95,8 +95,8 @@ export const reactNavigationIntegration = ({
9595
let recentRouteKeys: string[] = [];
9696

9797
if (enableTimeToInitialDisplay) {
98-
newScreenFrameEventEmitter = createSentryEventEmitter();
99-
newScreenFrameEventEmitter.initAsync(NewFrameEventName);
98+
newScreenFrameEventEmitter = createSentryFallbackEventEmitter();
99+
newScreenFrameEventEmitter.initAsync();
100100
NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => {
101101
logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`);
102102
});
@@ -258,9 +258,8 @@ export const reactNavigationIntegration = ({
258258
});
259259

260260
const navigationSpanWithTtid = latestNavigationSpan;
261-
!routeHasBeenSeen &&
262-
latestTtidSpan &&
263-
newScreenFrameEventEmitter?.once(NewFrameEventName, ({ newFrameTimestampInSeconds }: NewFrameEvent) => {
261+
if (!routeHasBeenSeen && latestTtidSpan) {
262+
newScreenFrameEventEmitter?.onceNewFrame(({ newFrameTimestampInSeconds }: NewFrameEvent) => {
264263
const activeSpan = getActiveSpan();
265264
if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) {
266265
logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.');
@@ -271,6 +270,7 @@ export const reactNavigationIntegration = ({
271270
latestTtidSpan.end(newFrameTimestampInSeconds);
272271
setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, navigationSpanWithTtid);
273272
});
273+
}
274274

275275
navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`);
276276
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { logger, timestampInSeconds } from '@sentry/utils';
2+
3+
import { NATIVE } from '../wrapper';
4+
import type { NewFrameEvent, SentryEventEmitter } from './sentryeventemitter';
5+
import { createSentryEventEmitter, NewFrameEventName } from './sentryeventemitter';
6+
7+
export const FALLBACK_TIMEOUT_MS = 10_000;
8+
9+
export type FallBackNewFrameEvent = { newFrameTimestampInSeconds: number; isFallback?: boolean };
10+
export interface SentryEventEmitterFallback {
11+
/**
12+
* Initializes the fallback event emitter
13+
* This method is synchronous in JS but the event emitter starts asynchronously.
14+
*/
15+
initAsync: () => void;
16+
onceNewFrame: (listener: (event: FallBackNewFrameEvent) => void) => void;
17+
}
18+
19+
/**
20+
* Creates emitter that allows to listen to UI Frame events when ready.
21+
*/
22+
export function createSentryFallbackEventEmitter(
23+
emitter: SentryEventEmitter = createSentryEventEmitter(),
24+
fallbackTimeoutMs = FALLBACK_TIMEOUT_MS,
25+
): SentryEventEmitterFallback {
26+
let fallbackTimeout: ReturnType<typeof setTimeout> | undefined;
27+
let animationFrameTimestampSeconds: number | undefined;
28+
let nativeNewFrameTimestampSeconds: number | undefined;
29+
30+
function getAnimationFrameTimestampSeconds(): void {
31+
// https://reactnative.dev/docs/timers#timers
32+
// NOTE: The current implementation of requestAnimationFrame is the same
33+
// as setTimeout(0). This isn't exactly how requestAnimationFrame
34+
// is supposed to work on web, so it doesn't get called when UI Frames are rendered.: https://github.com/facebook/react-native/blob/5106933c750fee2ce49fe1945c3e3763eebc92bc/packages/react-native/ReactCommon/react/runtime/TimerManager.cpp#L442-L443
35+
requestAnimationFrame(() => {
36+
if (fallbackTimeout === undefined) {
37+
return;
38+
}
39+
animationFrameTimestampSeconds = timestampInSeconds();
40+
});
41+
}
42+
43+
function getNativeNewFrameTimestampSeconds(): void {
44+
NATIVE.getNewScreenTimeToDisplay()
45+
.then(resolve => {
46+
if (fallbackTimeout === undefined) {
47+
return;
48+
}
49+
nativeNewFrameTimestampSeconds = resolve ?? undefined;
50+
})
51+
.catch(reason => {
52+
logger.error('Failed to receive Native fallback timestamp.', reason);
53+
});
54+
}
55+
56+
return {
57+
initAsync() {
58+
emitter.initAsync(NewFrameEventName);
59+
},
60+
61+
onceNewFrame(listener: (event: FallBackNewFrameEvent) => void) {
62+
animationFrameTimestampSeconds = undefined;
63+
nativeNewFrameTimestampSeconds = undefined;
64+
65+
const internalListener = (event: NewFrameEvent): void => {
66+
if (fallbackTimeout !== undefined) {
67+
clearTimeout(fallbackTimeout);
68+
fallbackTimeout = undefined;
69+
}
70+
animationFrameTimestampSeconds = undefined;
71+
nativeNewFrameTimestampSeconds = undefined;
72+
listener(event);
73+
};
74+
fallbackTimeout = setTimeout(() => {
75+
if (nativeNewFrameTimestampSeconds) {
76+
logger.log('Native event emitter did not reply in time');
77+
return listener({
78+
newFrameTimestampInSeconds: nativeNewFrameTimestampSeconds,
79+
isFallback: true,
80+
});
81+
} else if (animationFrameTimestampSeconds) {
82+
logger.log('[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.');
83+
return listener({
84+
newFrameTimestampInSeconds: animationFrameTimestampSeconds,
85+
isFallback: true,
86+
});
87+
} else {
88+
emitter.removeListener(NewFrameEventName, internalListener);
89+
logger.error('Failed to receive any fallback timestamp.');
90+
}
91+
}, fallbackTimeoutMs);
92+
93+
getNativeNewFrameTimestampSeconds();
94+
getAnimationFrameTimestampSeconds();
95+
emitter.once(NewFrameEventName, internalListener);
96+
},
97+
};
98+
}

packages/core/src/js/wrapper.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ interface SentryNativeWrapper {
116116
getCurrentReplayId(): string | null;
117117

118118
crashedLastRun(): Promise<boolean | null>;
119+
getNewScreenTimeToDisplay(): Promise<number | null | undefined>;
119120
}
120121

121122
const EOL = utf8ToBytes('\n');
@@ -690,6 +691,14 @@ export const NATIVE: SentryNativeWrapper = {
690691
return typeof result === 'boolean' ? result : null;
691692
},
692693

694+
getNewScreenTimeToDisplay(): Promise<number | null | undefined> {
695+
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
696+
return Promise.resolve(null);
697+
}
698+
699+
return RNSentry.getNewScreenTimeToDisplay();
700+
},
701+
693702
/**
694703
* Gets the event from envelopeItem and applies the level filter to the selected event.
695704
* @param data An envelope item containing the event.

packages/core/test/mockWrapper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const NATIVE: MockInterface<NativeType> = {
5858
getCurrentReplayId: jest.fn(),
5959

6060
crashedLastRun: jest.fn(),
61+
getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42),
6162
};
6263

6364
NATIVE.isNativeAvailable.mockReturnValue(true);

0 commit comments

Comments
 (0)