diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fd11a5f8..4eeb7ed92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Enhanced accuracy of time-to-display spans. ([#4042](https://github.com/getsentry/sentry-react-native/pull/4042)) - TimetoTisplay correctly warns about not supporting the new React Native architecture ([#4160](https://github.com/getsentry/sentry-react-native/pull/4160)) - Native Wrapper method `setContext` ensures only values convertible to NativeMap are passed ([#4168](https://github.com/getsentry/sentry-react-native/pull/4168)) - Native Wrapper method `setExtra` ensures only stringified values are passed ([#4168](https://github.com/getsentry/sentry-react-native/pull/4168)) diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index ecb41f1665..3c8a765971 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -41,15 +41,11 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; -import java.util.Set; import java.util.concurrent.CountDownLatch; -import io.sentry.Breadcrumb; -import io.sentry.DateUtils; import io.sentry.HubAdapter; import io.sentry.ILogger; import io.sentry.ISentryExecutorService; @@ -135,10 +131,13 @@ public class RNSentryModuleImpl { /** Max trace file size in bytes. */ private long maxTraceFileSize = 5 * 1024 * 1024; + private final @NotNull SentryDateProvider dateProvider; + public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { packageInfo = getPackageInfo(reactApplicationContext); this.reactApplicationContext = reactApplicationContext; this.emitNewFrameEvent = createEmitNewFrameEvent(); + this.dateProvider = new SentryAndroidDateProvider(); } private ReactApplicationContext getReactApplicationContext() { @@ -150,8 +149,6 @@ private ReactApplicationContext getReactApplicationContext() { } private @NotNull Runnable createEmitNewFrameEvent() { - final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); - return () -> { final SentryDate endDate = dateProvider.now(); WritableMap event = Arguments.createMap(); @@ -712,6 +709,10 @@ public void disableNativeFramesTracking() { } } + public void getNewScreenTimeToDisplay(Promise promise) { + RNSentryTimeToDisplay.GetTimeToDisplay(promise, dateProvider); + } + private String getProfilingTracesDirPath() { if (cacheDirPath == null) { cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); diff --git a/android/src/main/java/io/sentry/react/RNSentryPackage.java b/android/src/main/java/io/sentry/react/RNSentryPackage.java index 8cce37e297..cb62d63694 100644 --- a/android/src/main/java/io/sentry/react/RNSentryPackage.java +++ b/android/src/main/java/io/sentry/react/RNSentryPackage.java @@ -55,5 +55,4 @@ public List createViewManagers( new RNSentryOnDrawReporterManager(reactContext) ); } - } diff --git a/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java new file mode 100644 index 0000000000..9a31f182e8 --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -0,0 +1,39 @@ +package io.sentry.react; + +import com.facebook.react.bridge.Promise; + +import android.os.Handler; +import android.os.Looper; +import android.view.Choreographer; + +import org.jetbrains.annotations.NotNull; +import io.sentry.SentryDate; +import io.sentry.SentryDateProvider; +import io.sentry.android.core.SentryAndroidDateProvider; + +public class RNSentryTimeToDisplay { + public static void GetTimeToDisplay(Promise promise, SentryDateProvider dateProvider) { + Looper mainLooper = Looper.getMainLooper(); + + if (mainLooper == null) { + promise.reject("GetTimeToDisplay is not able to measure the time to display: Main looper not available."); + return; + } + + // Ensure the code runs on the main thread + new Handler(mainLooper) + .post(() -> { + try { + Choreographer choreographer = Choreographer.getInstance(); + + // Invoke the callback after the frame is rendered + choreographer.postFrameCallback(frameTimeNanos -> { + final SentryDate endDate = dateProvider.now(); + promise.resolve(endDate.nanoTimestamp() / 1e9); + }); + } catch (Exception exception) { + promise.reject("Failed to receive the instance of Choreographer", exception); + } + }); + } +} diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index fd5b902e54..f5868ce202 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -173,4 +173,9 @@ public String getCurrentReplayId() { public void crashedLastRun(Promise promise) { this.impl.crashedLastRun(promise); } + + @Override + public void getNewScreenTimeToDisplay(Promise promise) { + this.impl.getNewScreenTimeToDisplay(promise); + } } diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 6b135ecb90..d69f330e9f 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -173,4 +173,9 @@ public String getCurrentReplayId() { public void crashedLastRun(Promise promise) { this.impl.crashedLastRun(promise); } + + @ReactMethod() + public void getNewScreenTimeToDisplay(Promise promise) { + this.impl.getNewScreenTimeToDisplay(promise); + } } diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index a04733e7b5..521a336ece 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -1,5 +1,6 @@ #import #import "RNSentry.h" +#import "RNSentryTimeToDisplay.h" #if __has_include() #import @@ -62,6 +63,7 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; @implementation RNSentry { bool sentHybridSdkDidBecomeActive; bool hasListeners; + RNSentryTimeToDisplay *_timeToDisplay; } - (dispatch_queue_t)methodQueue @@ -106,6 +108,8 @@ + (BOOL)requiresMainQueueSetup { sentHybridSdkDidBecomeActive = true; } + _timeToDisplay = [[RNSentryTimeToDisplay alloc] init]; + #if SENTRY_TARGET_REPLAY_SUPPORTED [RNSentryReplay postInit]; #endif @@ -786,4 +790,9 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd } #endif +RCT_EXPORT_METHOD(getNewScreenTimeToDisplay:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_timeToDisplay getTimeToDisplay:resolve]; +} + @end diff --git a/ios/RNSentryTimeToDisplay.h b/ios/RNSentryTimeToDisplay.h new file mode 100644 index 0000000000..fbb468cb23 --- /dev/null +++ b/ios/RNSentryTimeToDisplay.h @@ -0,0 +1,7 @@ +#import + +@interface RNSentryTimeToDisplay : NSObject + +- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback; + +@end diff --git a/ios/RNSentryTimeToDisplay.m b/ios/RNSentryTimeToDisplay.m new file mode 100644 index 0000000000..88e24eedc7 --- /dev/null +++ b/ios/RNSentryTimeToDisplay.m @@ -0,0 +1,43 @@ +#import "RNSentryTimeToDisplay.h" +#import +#import + +@implementation RNSentryTimeToDisplay +{ + CADisplayLink *displayLink; + RCTResponseSenderBlock resolveBlock; +} + +// Rename requestAnimationFrame to getTimeToDisplay +- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback +{ + // Store the resolve block to use in the callback. + resolveBlock = callback; + +#if TARGET_OS_IOS + // Create and add a display link to get the callback after the screen is rendered. + displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)]; + [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +#else + resolveBlock(@[]); // Return nothing if not iOS. +#endif +} + +#if TARGET_OS_IOS +- (void)handleDisplayLink:(CADisplayLink *)link { + // Get the current time + NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970] * 1000.0; // Convert to milliseconds + + // Ensure the callback is valid and pass the current time back + if (resolveBlock) { + resolveBlock(@[@(currentTime)]); // Call the callback with the current time + resolveBlock = nil; // Clear the block after it's called + } + + // Invalidate the display link to stop future callbacks + [displayLink invalidate]; + displayLink = nil; +} +#endif + +@end diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index a5a7652fba..5b0880517b 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -9,6 +9,7 @@ import type { UnsafeObject } from './utils/rnlibrariesinterface'; export interface Spec extends TurboModule { addListener: (eventType: string) => void; removeListeners: (id: number) => void; + getNewScreenTimeToDisplay(): Promise; addBreadcrumb(breadcrumb: UnsafeObject): void; captureEnvelope( bytes: string, @@ -16,6 +17,7 @@ export interface Spec extends TurboModule { hardCrashed: boolean; }, ): Promise; + captureScreenshot(): Promise; clearBreadcrumbs(): void; crash(): void; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 4de2ee5c08..39572419ea 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -4,7 +4,8 @@ import type { Span, Transaction as TransactionType, TransactionContext } from '@ import { logger, timestampInSeconds } from '@sentry/utils'; import type { NewFrameEvent } from '../utils/sentryeventemitter'; -import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter'; +import type { SentryEventEmitterFallback } from '../utils/sentryeventemitterfallback'; +import { createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; @@ -77,8 +78,7 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati public readonly name: string = ReactNavigationInstrumentation.instrumentationName; private _navigationContainer: NavigationContainer | null = null; - private _newScreenFrameEventEmitter: SentryEventEmitter | null = null; - + private _newScreenFrameEventEmitter: SentryEventEmitterFallback | null = null; private readonly _maxRecentRouteLen: number = 200; private _latestRoute?: NavigationRoute; @@ -86,7 +86,7 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati private _navigationProcessingSpan?: Span; private _initialStateHandled: boolean = false; - private _stateChangeTimeout?: number | undefined; + private _stateChangeTimeout?: ReturnType | undefined; private _recentRouteKeys: string[] = []; private _options: ReactNavigationOptions; @@ -100,8 +100,8 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati }; if (this._options.enableTimeToInitialDisplay) { - this._newScreenFrameEventEmitter = createSentryEventEmitter(); - this._newScreenFrameEventEmitter.initAsync(NewFrameEventName); + this._newScreenFrameEventEmitter = createSentryFallbackEventEmitter(); + this._newScreenFrameEventEmitter.initAsync(); NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); }); @@ -247,24 +247,21 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati isAutoInstrumented: true, }); - !routeHasBeenSeen && - latestTtidSpan && - this._newScreenFrameEventEmitter?.once( - NewFrameEventName, - ({ newFrameTimestampInSeconds }: NewFrameEvent) => { - const activeSpan = getActiveSpan(); - if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { - logger.warn( - '[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.', - ); - return; - } - - latestTtidSpan.setStatus('ok'); - latestTtidSpan.end(newFrameTimestampInSeconds); - setSpanDurationAsMeasurementOnTransaction(latestTransaction, 'time_to_initial_display', latestTtidSpan); - }, - ); + if (!routeHasBeenSeen && latestTtidSpan) { + this._newScreenFrameEventEmitter?.onceNewFrame(({ newFrameTimestampInSeconds }: NewFrameEvent) => { + const activeSpan = getActiveSpan(); + if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { + logger.warn( + '[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.', + ); + return; + } + + latestTtidSpan.setStatus('ok'); + latestTtidSpan.end(newFrameTimestampInSeconds); + setSpanDurationAsMeasurementOnTransaction(latestTransaction, 'time_to_initial_display', latestTtidSpan); + }); + } this._navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); this._navigationProcessingSpan?.setStatus('ok'); diff --git a/src/js/utils/sentryeventemitterfallback.ts b/src/js/utils/sentryeventemitterfallback.ts new file mode 100644 index 0000000000..8eaeb947a8 --- /dev/null +++ b/src/js/utils/sentryeventemitterfallback.ts @@ -0,0 +1,98 @@ +import { logger, timestampInSeconds } from '@sentry/utils'; + +import { NATIVE } from '../wrapper'; +import type { NewFrameEvent, SentryEventEmitter } from './sentryeventemitter'; +import { createSentryEventEmitter, NewFrameEventName } from './sentryeventemitter'; + +export const FALLBACK_TIMEOUT_MS = 10_000; + +export type FallBackNewFrameEvent = { newFrameTimestampInSeconds: number; isFallback?: boolean }; +export interface SentryEventEmitterFallback { + /** + * Initializes the fallback event emitter + * This method is synchronous in JS but the event emitter starts asynchronously. + */ + initAsync: () => void; + onceNewFrame: (listener: (event: FallBackNewFrameEvent) => void) => void; +} + +/** + * Creates emitter that allows to listen to UI Frame events when ready. + */ +export function createSentryFallbackEventEmitter( + emitter: SentryEventEmitter = createSentryEventEmitter(), + fallbackTimeoutMs = FALLBACK_TIMEOUT_MS, +): SentryEventEmitterFallback { + let fallbackTimeout: ReturnType | undefined; + let animationFrameTimestampSeconds: number | undefined; + let nativeNewFrameTimestampSeconds: number | undefined; + + function getAnimationFrameTimestampSeconds(): void { + // https://reactnative.dev/docs/timers#timers + // NOTE: The current implementation of requestAnimationFrame is the same + // as setTimeout(0). This isn't exactly how requestAnimationFrame + // 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 + requestAnimationFrame(() => { + if (fallbackTimeout === undefined) { + return; + } + animationFrameTimestampSeconds = timestampInSeconds(); + }); + } + + function getNativeNewFrameTimestampSeconds(): void { + NATIVE.getNewScreenTimeToDisplay() + .then(resolve => { + if (fallbackTimeout === undefined) { + return; + } + nativeNewFrameTimestampSeconds = resolve ?? undefined; + }) + .catch(reason => { + logger.error('Failed to receive Native fallback timestamp.', reason); + }); + } + + return { + initAsync() { + emitter.initAsync(NewFrameEventName); + }, + + onceNewFrame(listener: (event: FallBackNewFrameEvent) => void) { + animationFrameTimestampSeconds = undefined; + nativeNewFrameTimestampSeconds = undefined; + + const internalListener = (event: NewFrameEvent): void => { + if (fallbackTimeout !== undefined) { + clearTimeout(fallbackTimeout); + fallbackTimeout = undefined; + } + animationFrameTimestampSeconds = undefined; + nativeNewFrameTimestampSeconds = undefined; + listener(event); + }; + fallbackTimeout = setTimeout(() => { + if (nativeNewFrameTimestampSeconds) { + logger.log('Native event emitter did not reply in time'); + return listener({ + newFrameTimestampInSeconds: nativeNewFrameTimestampSeconds, + isFallback: true, + }); + } else if (animationFrameTimestampSeconds) { + logger.log('[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.'); + return listener({ + newFrameTimestampInSeconds: animationFrameTimestampSeconds, + isFallback: true, + }); + } else { + emitter.removeListener(NewFrameEventName, internalListener); + logger.error('Failed to receive any fallback timestamp.'); + } + }, fallbackTimeoutMs); + + getNativeNewFrameTimestampSeconds(); + getAnimationFrameTimestampSeconds(); + emitter.once(NewFrameEventName, internalListener); + }, + }; +} diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index ac9915d7c8..047f2547cd 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -116,6 +116,7 @@ interface SentryNativeWrapper { getCurrentReplayId(): string | null; crashedLastRun(): Promise; + getNewScreenTimeToDisplay(): Promise; } const EOL = utf8ToBytes('\n'); @@ -689,6 +690,14 @@ export const NATIVE: SentryNativeWrapper = { return typeof result === 'boolean' ? result : null; }, + getNewScreenTimeToDisplay(): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + return RNSentry.getNewScreenTimeToDisplay(); + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/mockWrapper.ts b/test/mockWrapper.ts index 2d22458a61..02cb6a6755 100644 --- a/test/mockWrapper.ts +++ b/test/mockWrapper.ts @@ -55,6 +55,7 @@ const NATIVE: MockInterface = { initNativeReactNavigationNewFrameTracking: jest.fn(), crashedLastRun: jest.fn(), + getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), }; NATIVE.isNativeAvailable.mockReturnValue(true); diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index b1357dcdd1..b86689bc86 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -1,9 +1,9 @@ import * as mockWrapper from '../mockWrapper'; -import * as mockedSentryEventEmitter from '../utils/mockedSentryeventemitter'; +import * as mockedSentryEventEmitter from '../utils/mockedSentryeventemitterfallback'; import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/wrapper', () => mockWrapper); jest.mock('../../src/js/utils/environment'); -jest.mock('../../src/js/utils/sentryeventemitter', () => mockedSentryEventEmitter); +jest.mock('../../src/js/utils/sentryeventemitterfallback', () => mockedSentryEventEmitter); jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); import { Span } from '@sentry/core'; @@ -16,16 +16,16 @@ import * as Sentry from '../../src/js'; import { ReactNavigationInstrumentation } from '../../src/js'; import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing'; import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; -import { createSentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; +import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { MOCK_DSN } from '../mockDsn'; import { secondInFutureTimestampMs } from '../testutils'; -import type { MockedSentryEventEmitter } from '../utils/mockedSentryeventemitter'; +import type { MockedSentryEventEmitterFallback } from '../utils/mockedSentryeventemitterfallback'; import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; describe('React Navigation - TTID', () => { - let mockedEventEmitter: MockedSentryEventEmitter; + let mockedEventEmitter: MockedSentryEventEmitterFallback; let transportSendMock: jest.Mock, Parameters>; let mockedNavigation: ReturnType; const mockedAppStartTimeSeconds: number = timestampInSeconds(); @@ -43,8 +43,8 @@ describe('React Navigation - TTID', () => { spans: [], }); - mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryEventEmitter(); - (createSentryEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); + mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryFallbackEventEmitter(); + (createSentryFallbackEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); const sut = createTestedInstrumentation({ enableTimeToInitialDisplay: true }); transportSendMock = initSentry(sut).transportSendMock; diff --git a/test/utils/mockedSentryeventemitter.ts b/test/utils/mockedSentryeventemitter.ts deleted file mode 100644 index 3efc901b55..0000000000 --- a/test/utils/mockedSentryeventemitter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { timestampInSeconds } from '@sentry/utils'; -import EventEmitter from 'events'; - -import type { NewFrameEvent, SentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; -import type { MockInterface } from '../testutils'; - -export const NewFrameEventName = 'rn_sentry_new_frame'; -export type NewFrameEventName = typeof NewFrameEventName; - -export interface MockedSentryEventEmitter extends MockInterface { - emitNewFrameEvent: (timestampSeconds?: number) => void; -} - -export function createMockedSentryEventEmitter(): MockedSentryEventEmitter { - const emitter = new EventEmitter(); - - return { - emitNewFrameEvent: jest.fn((timestampSeconds?: number) => { - emitter.emit('rn_sentry_new_frame', { - newFrameTimestampInSeconds: timestampSeconds || timestampInSeconds(), - }); - }), - once: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { - emitter.once(event, listener); - }), - removeListener: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { - emitter.removeListener(event, listener); - }), - addListener: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { - emitter.addListener(event, listener); - }), - initAsync: jest.fn(), - closeAllAsync: jest.fn(() => { - emitter.removeAllListeners(); - }), - }; -} - -export const createSentryEventEmitter = jest.fn(() => createMockedSentryEventEmitter()); diff --git a/test/utils/mockedSentryeventemitterfallback.ts b/test/utils/mockedSentryeventemitterfallback.ts new file mode 100644 index 0000000000..50d5fbcc27 --- /dev/null +++ b/test/utils/mockedSentryeventemitterfallback.ts @@ -0,0 +1,26 @@ +import { timestampInSeconds } from '@sentry/utils'; +import EventEmitter from 'events'; + +import type { NewFrameEvent } from '../../src/js/utils/sentryeventemitter'; +import type { SentryEventEmitterFallback } from '../../src/js/utils/sentryeventemitterfallback'; +import type { MockInterface } from '../testutils'; +export const NewFrameEventName = 'rn_sentry_new_frame'; +export type NewFrameEventName = typeof NewFrameEventName; +export interface MockedSentryEventEmitterFallback extends MockInterface { + emitNewFrameEvent: (timestampSeconds?: number) => void; +} +export function createMockedSentryFallbackEventEmitter(): MockedSentryEventEmitterFallback { + const emitter = new EventEmitter(); + return { + initAsync: jest.fn(), + emitNewFrameEvent: jest.fn((timestampSeconds?: number) => { + emitter.emit(NewFrameEventName, { + newFrameTimestampInSeconds: timestampSeconds || timestampInSeconds(), + }); + }), + onceNewFrame: jest.fn((listener: (event: NewFrameEvent) => void) => { + emitter.once(NewFrameEventName, listener); + }), + }; +} +export const createSentryFallbackEventEmitter = jest.fn(() => createMockedSentryFallbackEventEmitter()); diff --git a/test/utils/sentryeventemitterfallback.test.ts b/test/utils/sentryeventemitterfallback.test.ts new file mode 100644 index 0000000000..6f8f9ff0d2 --- /dev/null +++ b/test/utils/sentryeventemitterfallback.test.ts @@ -0,0 +1,236 @@ +import { NewFrameEventName } from '../../src/js/utils/sentryeventemitter'; +import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; + +// Mock dependencies +jest.mock('../../src/js/utils/environment', () => ({ + isTurboModuleEnabled: () => false, +})); + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); + +jest.spyOn(logger, 'warn'); +jest.spyOn(logger, 'log'); +jest.spyOn(logger, 'error'); + +import { logger } from '@sentry/utils'; + +import { NATIVE } from '../../src/js/wrapper'; + +describe('SentryEventEmitterFallback', () => { + let emitter: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + // @ts-expect-error test + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + emitter = createSentryFallbackEventEmitter(); + NATIVE.getNewScreenTimeToDisplay = jest.fn(() => Promise.resolve(12345)); + }); + + afterEach(() => { + // @ts-expect-error test + window.requestAnimationFrame.mockRestore(); + NATIVE.getNewScreenTimeToDisplay = jest.fn(); + }); + + it('should start listener and use fallback when native call returned undefined/null', async () => { + jest.useFakeTimers(); + const spy = jest.spyOn(require('@sentry/utils'), 'timestampInSeconds'); + const fallbackTime = Date.now() / 1000; + spy.mockReturnValue(fallbackTime); + + (NATIVE.getNewScreenTimeToDisplay as jest.Mock).mockReturnValue(Promise.resolve()); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalledWith('Failed to receive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(listener).toHaveBeenCalledWith({ + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call fails', async () => { + jest.useFakeTimers(); + + (NATIVE.getNewScreenTimeToDisplay as jest.Mock).mockRejectedValue(new Error('Failed')); + + const spy = jest.spyOn(require('@sentry/utils'), 'timestampInSeconds'); + const fallbackTime = Date.now() / 1000; + spy.mockReturnValue(fallbackTime); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Failed to receive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(listener).toHaveBeenCalledWith({ + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call fails', async () => { + jest.useFakeTimers(); + const spy = jest.spyOn(require('@sentry/utils'), 'timestampInSeconds'); + const fallbackTime = Date.now() / 1000; + spy.mockReturnValue(fallbackTime); + + (NATIVE.getNewScreenTimeToDisplay as jest.Mock).mockRejectedValue(new Error('Failed')); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + await expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Failed to receive Native fallback timestamp.', expect.any(Error)); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(listener).toHaveBeenCalledWith({ + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and use fallback when native call is not available', async () => { + jest.useFakeTimers(); + const spy = jest.spyOn(require('@sentry/utils'), 'timestampInSeconds'); + const fallbackTime = Date.now() / 1000; + spy.mockReturnValue(fallbackTime); + + NATIVE.getNewScreenTimeToDisplay = () => Promise.resolve(null); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(listener).toHaveBeenCalledWith({ + newFrameTimestampInSeconds: fallbackTime, + isFallback: true, + }); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining( + '[Sentry] Native event emitter did not reply in time. Using JavaScript fallback emitter.', + ), + ); + }); + + it('should start listener and call native when native module is available', async () => { + const nativeTimestamp = 12345; + + (NATIVE.getNewScreenTimeToDisplay as jest.Mock).mockResolvedValueOnce(nativeTimestamp); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + expect(NATIVE.getNewScreenTimeToDisplay).toHaveBeenCalled(); + }); + + it('should not emit if original event emitter was called', async () => { + jest.useFakeTimers(); + + // Capture the callback passed to addListener + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/ban-types + let callback: Function = () => {}; + const mockOnce = jest.fn().mockImplementationOnce((eventName, cb) => { + if (eventName === NewFrameEventName) { + callback = cb; + } + return { + remove: jest.fn(), + }; + }); + + emitter = createSentryFallbackEventEmitter({ + addListener: jest.fn(), + initAsync: jest.fn(), + closeAllAsync: jest.fn(), + removeListener: jest.fn(), + once: mockOnce, + }); + + emitter.initAsync(); + const listener = jest.fn(); + emitter.onceNewFrame(listener); + callback({ + newFrameTimestampInSeconds: 67890, + }); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + // Simulate retries and timer + jest.runAllTimers(); + + // Ensure fallback event is emitted + expect(listener).toHaveBeenCalledWith({ + newFrameTimestampInSeconds: 67890, + isFallback: undefined, + }); + expect(logger.log).not.toBeCalled(); + }); + + it('should retry up to maxRetries and emit fallback if no response', async () => { + jest.useFakeTimers(); + + const listener = jest.fn(); + emitter.onceNewFrame(listener); + + // Wait for the next event loop to allow startListenerAsync to call NATIVE.getNewScreenTimeToDisplay + await Promise.resolve(); + + expect(logger.log).not.toHaveBeenCalled(); + + // Simulate retries and timer + jest.runAllTimers(); + + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ isFallback: true })); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Native event emitter did not reply in time')); + + jest.useRealTimers(); + }); +});