From 290f3c3ec2ac4b99be9c1a11dfce5b1be10e8ce1 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 14 Mar 2025 16:00:52 +0100 Subject: [PATCH 01/33] feat(ttid): Add support for measuring Time to Initial Display for already seen routes - Introduced `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration. - Updated logic to measure Time to Initial Display for routes that have already been seen. - Added tests to verify functionality for preloaded routes in the tracing module. --- CHANGELOG.md | 8 +++ .../core/src/js/tracing/reactnavigation.ts | 22 ++++++-- .../tracing/reactnavigation.ttid.test.tsx | 56 ++++++++++++++++++- samples/react-native/src/App.tsx | 1 + 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c355e2eb77..c96721f005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ - Add thread information to spans ([#4579](https://github.com/getsentry/sentry-react-native/pull/4579)) - Exposed `getDataFromUri` as a public API to retrieve data from a URI ([#4638](https://github.com/getsentry/sentry-react-native/pull/4638)) - Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641)) +- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661)) + - Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration. + + ```js + Sentry.reactNavigationIntegration({ + enableTimeToInitialDisplayForPreloadedRoutes: true, + }); + ``` ### Fixes diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 362030db79..9ffdc64f4e 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -62,6 +62,14 @@ interface ReactNavigationIntegrationOptions { * @default true */ ignoreEmptyBackNavigationTransactions: boolean; + + /** + * Enabled measuring Time to Initial Display for routes that are already loaded in memory. + * (a.k.a., Routes that the navigation integration has already seen.) + * + * @default false + */ + enableTimeToInitialDisplayForPreloadedRoutes: boolean; } /** @@ -76,6 +84,7 @@ export const reactNavigationIntegration = ({ routeChangeTimeoutMs = 1_000, enableTimeToInitialDisplay = false, ignoreEmptyBackNavigationTransactions = true, + enableTimeToInitialDisplayForPreloadedRoutes = false, }: Partial = {}): Integration & { /** * Pass the ref to the navigation container to register it to the instrumentation @@ -268,16 +277,19 @@ export const reactNavigationIntegration = ({ } const routeHasBeenSeen = recentRouteKeys.includes(route.key); - const latestTtidSpan = - !routeHasBeenSeen && - enableTimeToInitialDisplay && - startTimeToInitialDisplaySpan({ + const startTtidForNewRoute = enableTimeToInitialDisplay && !routeHasBeenSeen; + const startTtidForAllRoutes = enableTimeToInitialDisplay && enableTimeToInitialDisplayForPreloadedRoutes; + + let latestTtidSpan: Span | undefined = undefined; + if (startTtidForNewRoute || startTtidForAllRoutes) { + latestTtidSpan = startTimeToInitialDisplaySpan({ name: `${route.name} initial display`, isAutoInstrumented: true, }); + } const navigationSpanWithTtid = latestNavigationSpan; - if (!routeHasBeenSeen && latestTtidSpan) { + if (latestTtidSpan) { newScreenFrameEventEmitter?.onceNewFrame(({ newFrameTimestampInSeconds }: NewFrameEvent) => { const activeSpan = getActiveSpan(); if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index 387d6b9799..9feae424b0 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -589,6 +589,55 @@ describe('React Navigation - TTID', () => { }); }); + describe('ttid for preloaded/seen routes', () => { + beforeEach(() => { + jest.useFakeTimers(); + (notWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should add ttid span and measurement for already seen route', () => { + const sut = createTestedInstrumentation({ + enableTimeToInitialDisplay: true, + ignoreEmptyBackNavigationTransactions: false, + enableTimeToInitialDisplayForPreloadedRoutes: true, + }); + transportSendMock = initSentry(sut).transportSendMock; + + mockedNavigation = createMockNavigationAndAttachTo(sut); + + jest.runOnlyPendingTimers(); // Flush app start transaction + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + mockedNavigation.navigateToInitialScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + op: 'ui.load.initial_display', + description: 'Initial Screen initial display', + }), + ]), + measurements: expect.objectContaining['measurements']>({ + time_to_initial_display: { + value: expect.any(Number), + unit: 'millisecond', + }, + }), + }), + ); + }); + }); + function getSpanDurationMs(transaction: TransactionEvent, op: string): number | undefined { const ttidSpan = transaction.spans?.find(span => span.op === op); if (!ttidSpan) { @@ -603,10 +652,13 @@ describe('React Navigation - TTID', () => { return (spanJSON.timestamp - spanJSON.start_timestamp) * 1000; } - function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) { + function createTestedInstrumentation(options?: { + enableTimeToInitialDisplay?: boolean + enableTimeToInitialDisplayForPreloadedRoutes?: boolean + ignoreEmptyBackNavigationTransactions?: boolean + }) { const sut = Sentry.reactNavigationIntegration({ ...options, - ignoreEmptyBackNavigationTransactions: true, // default true }); return sut; } diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index d74a396be5..63effaeba6 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -53,6 +53,7 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({ routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms enableTimeToInitialDisplay: isMobileOs, ignoreEmptyBackNavigationTransactions: true, + enableTimeToInitialDisplayForPreloadedRoutes: true, }); Sentry.init({ From 9520bfd30f0787bce9762ff0d9f88ca12ce26b32 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 14 Mar 2025 17:19:44 +0100 Subject: [PATCH 02/33] fix(ttd): Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID --- CHANGELOG.md | 1 + .../core/src/js/tracing/reactnavigation.ts | 10 ++++----- .../core/src/js/tracing/timetodisplay.tsx | 22 ++++++++++++++----- .../tracing/reactnavigation.ttid.test.tsx | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96721f005..9ccfda406e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Handle non-string category in getCurrentScreen on iOS ([#4629](https://github.com/getsentry/sentry-react-native/pull/4629)) - Use route name instead of route key for current route tracking ([#4650](https://github.com/getsentry/sentry-react-native/pull/4650)) - Using key caused user interaction transaction names to contain route hash in the name. +- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4662](https://github.com/getsentry/sentry-react-native/pull/4662)) ### Dependencies diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 9ffdc64f4e..22267f1809 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -32,8 +32,7 @@ import { getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan, } from './span'; -import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; -import { setSpanDurationAsMeasurementOnSpan } from './utils'; +import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan, updateInitialDisplaySpan } from './timetodisplay'; export const INTEGRATION_NAME = 'ReactNavigation'; const NAVIGATION_HISTORY_MAX_SIZE = 200; @@ -297,9 +296,10 @@ export const reactNavigationIntegration = ({ return; } - latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); - latestTtidSpan.end(newFrameTimestampInSeconds); - setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, navigationSpanWithTtid); + updateInitialDisplaySpan(newFrameTimestampInSeconds, { + activeSpan: navigationSpanWithTtid, + span: latestTtidSpan, + }); }); } diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 12d1198bc4..94f2aaf7a8 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -6,7 +6,7 @@ import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; -import { setSpanDurationAsMeasurement } from './utils'; +import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; let nativeComponentMissingLogged = false; @@ -206,14 +206,26 @@ function onDrawNextFrame(event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): } } -function updateInitialDisplaySpan(frameTimestampSeconds: number): void { - const span = startTimeToInitialDisplaySpan(); +/** + * + */ +export function updateInitialDisplaySpan( + frameTimestampSeconds: number, + { + activeSpan = getActiveSpan(), + span = startTimeToInitialDisplaySpan(), + }: { + activeSpan?: Span; + /** + * Time to initial display span to update. + */ + span?: Span; + } = {}): void { if (!span) { logger.warn(`[TimeToDisplay] No span found or created, possibly performance is disabled.`); return; } - const activeSpan = getActiveSpan(); if (!activeSpan) { logger.warn(`[TimeToDisplay] No active span found to attach ui.load.initial_display to.`); return; @@ -239,7 +251,7 @@ function updateInitialDisplaySpan(frameTimestampSeconds: number): void { updateFullDisplaySpan(frameTimestampSeconds, span); } - setSpanDurationAsMeasurement('time_to_initial_display', span); + setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); } function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void { diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index 9feae424b0..f134b5b8f8 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -351,6 +351,28 @@ describe('React Navigation - TTID', () => { expect(getSpanDurationMs(transaction, 'ui.load.initial_display')).toEqual(transaction.measurements?.time_to_initial_display?.value); }); + test('ttfd span duration and measurement should equal ttid from ttfd is called earlier than ttid', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + TestRenderer.render(); + emitNativeFullDisplayEvent(); + mockedEventEmitter.emitNewFrameEvent(); + + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + const ttfdSpanDuration = getSpanDurationMs(transaction, 'ui.load.full_display'); + const ttidSpanDuration = getSpanDurationMs(transaction, 'ui.load.initial_display'); + expect(ttfdSpanDuration).toBeDefined(); + expect(ttidSpanDuration).toBeDefined(); + expect(ttfdSpanDuration).toEqual(ttidSpanDuration); + + expect(transaction.measurements?.time_to_full_display?.value).toBeDefined(); + expect(transaction.measurements?.time_to_initial_display?.value).toBeDefined(); + expect(transaction.measurements?.time_to_full_display?.value).toEqual(transaction.measurements?.time_to_initial_display?.value); + }); + test('ttfd span duration and measurement should equal for application start up', () => { mockedNavigation.finishAppStartNavigation(); mockedEventEmitter.emitNewFrameEvent(); From fd981786345e1be7cda153968509bccff8a72f49 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 18 Mar 2025 18:05:01 +0100 Subject: [PATCH 03/33] feat(ttd): Add `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus --- packages/core/src/js/index.ts | 1 + .../core/src/js/tracing/timetodisplay.tsx | 25 +++- ...=> captureErrorsScreenTransaction.test.ts} | 62 +--------- ...> captureErrorsScreenTransaction.test.yml} | 0 ...reSpaceflightNewsScreenTransaction.test.ts | 107 ++++++++++++++++++ ...eSpaceflightNewsScreenTransaction.test.yml | 30 +++++ .../e2e/utils/mockedSentryServer.ts | 29 ++++- samples/react-native/src/App.tsx | 4 +- .../src/Screens/SpaceflightNewsScreen.tsx | 19 +++- .../src/components/ArticleCard.tsx | 6 +- 10 files changed, 215 insertions(+), 68 deletions(-) rename samples/react-native/e2e/{captureTransaction.test.ts => captureErrorsScreenTransaction.test.ts} (65%) rename samples/react-native/e2e/{captureTransaction.test.yml => captureErrorsScreenTransaction.test.yml} (100%) create mode 100644 samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts create mode 100644 samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.yml diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index b5c3205fd4..22c7983dd5 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -79,6 +79,7 @@ export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions, + createTimeToFullDisplay, } from './tracing'; export type { TimeToDisplayProps } from './tracing'; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 94f2aaf7a8..e0b8229b2e 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -1,5 +1,5 @@ import type { Span,StartSpanOptions } from '@sentry/core'; -import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core'; +import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, uuid4 } from '@sentry/core'; import * as React from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; @@ -7,6 +7,7 @@ import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISP import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; +import { useState } from 'react'; let nativeComponentMissingLogged = false; @@ -296,3 +297,25 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl setSpanDurationAsMeasurement('time_to_full_display', span); } + +export function createTimeToFullDisplay({ + useFocusEffect, +}: { + /** + * `@react-navigation/native` useFocusEffect hook. + */ + useFocusEffect: (callback: () => void) => void +}) { + return (props: TimeToDisplayProps) => { + const [focused, setFocused] = useState(false); + + useFocusEffect(() => { + setFocused(true); + return () => { + setFocused(false); + }; + }); + + return ; + }; +} diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureErrorsScreenTransaction.test.ts similarity index 65% rename from samples/react-native/e2e/captureTransaction.test.ts rename to samples/react-native/e2e/captureErrorsScreenTransaction.test.ts index f6716dd81c..cc43608511 100644 --- a/samples/react-native/e2e/captureTransaction.test.ts +++ b/samples/react-native/e2e/captureErrorsScreenTransaction.test.ts @@ -8,27 +8,21 @@ import { import { getItemOfTypeFrom } from './utils/event'; import { maestro } from './utils/maestro'; -describe('Capture transaction', () => { +describe('Capture Errors Screen Transaction', () => { let sentryServer = createSentryServer(); sentryServer.start(); const getErrorsEnvelope = () => sentryServer.getEnvelope(containingTransactionWithName('Errors')); - const getTrackerEnvelope = () => - sentryServer.getEnvelope(containingTransactionWithName('Tracker')); - beforeAll(async () => { - const waitForTrackerTx = sentryServer.waitForEnvelope( - containingTransactionWithName('Tracker'), // The last created and sent transaction - ); const waitForErrorsTx = sentryServer.waitForEnvelope( containingTransactionWithName('Errors'), // The last created and sent transaction ); - await maestro('captureTransaction.test.yml'); + await maestro('captureErrorsScreenTransaction.test.yml'); - await Promise.all([waitForTrackerTx, waitForErrorsTx]); + await waitForErrorsTx; }); afterAll(async () => { @@ -137,54 +131,4 @@ describe('Capture transaction', () => { }), ); }); - - it('contains time to display measurements', async () => { - const item = getItemOfTypeFrom( - getTrackerEnvelope(), - 'transaction', - ); - - expect(item?.[1]).toEqual( - expect.objectContaining({ - measurements: expect.objectContaining({ - time_to_initial_display: { - unit: 'millisecond', - value: expect.any(Number), - }, - time_to_full_display: { - unit: 'millisecond', - value: expect.any(Number), - }, - }), - }), - ); - }); - - it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => { - const item = getItemOfTypeFrom( - getTrackerEnvelope(), - 'transaction', - ); - - expect(item?.[1]).toEqual( - expect.objectContaining({ - breadcrumbs: expect.arrayContaining([ - expect.objectContaining({ - category: 'xhr', - data: { - end_timestamp: expect.any(Number), - method: 'GET', - response_body_size: expect.any(Number), - start_timestamp: expect.any(Number), - status_code: expect.any(Number), - url: expect.stringContaining('api.covid19api.com/summary'), - }, - level: 'info', - timestamp: expect.any(Number), - type: 'http', - }), - ]), - }), - ); - }); }); diff --git a/samples/react-native/e2e/captureTransaction.test.yml b/samples/react-native/e2e/captureErrorsScreenTransaction.test.yml similarity index 100% rename from samples/react-native/e2e/captureTransaction.test.yml rename to samples/react-native/e2e/captureErrorsScreenTransaction.test.yml diff --git a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts new file mode 100644 index 0000000000..157a18a03f --- /dev/null +++ b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts @@ -0,0 +1,107 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; +import { + createSentryServer, + containingTransactionWithName, + takeSecond, +} from './utils/mockedSentryServer'; + +import { getItemOfTypeFrom } from './utils/event'; +import { maestro } from './utils/maestro'; + +describe('Capture Spaceflight News Screen Transaction', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelopes: Envelope[] = []; + + const getFirstTransactionEnvelopeItem = () => + getItemOfTypeFrom(envelopes[0], 'transaction'); + + const getSecondTransactionEnvelopeItem = () => + getItemOfTypeFrom(envelopes[1], 'transaction'); + + beforeAll(async () => { + const containingNewsScreen = containingTransactionWithName( + 'SpaceflightNewsScreen', + ); + const waitForSpaceflightNewsTx = sentryServer.waitForEnvelope( + takeSecond(containingNewsScreen), + ); + + await maestro('captureSpaceflightNewsScreenTransaction.test.yml'); + + await waitForSpaceflightNewsTx; + + envelopes = sentryServer.getAllEnvelopes(containingNewsScreen); + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('first received new screen transaction was created before the second visit', async () => { + const first = getFirstTransactionEnvelopeItem(); + const second = getSecondTransactionEnvelopeItem(); + + expect(first?.[1].timestamp).toBeDefined(); + expect(second?.[1].timestamp).toBeDefined(); + expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!); + }); + + it('contains time to display measurements on the first visit', async () => { + expectToContainTimeToDisplayMeasurements(getFirstTransactionEnvelopeItem()); + }); + + it('contains time to display measurements on the second visit', async () => { + expectToContainTimeToDisplayMeasurements( + getSecondTransactionEnvelopeItem(), + ); + }); + + function expectToContainTimeToDisplayMeasurements( + item: EventItem | undefined, + ) { + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + time_to_full_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + } + + it('contains at least one xhr breadcrumb of request to the news endpoint', async () => { + const item = getFirstTransactionEnvelopeItem(); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: 'xhr', + data: { + end_timestamp: expect.any(Number), + method: 'GET', + response_body_size: expect.any(Number), + start_timestamp: expect.any(Number), + status_code: expect.any(Number), + url: expect.stringContaining( + 'api.spaceflightnewsapi.net/v4/articles', + ), + }, + level: 'info', + timestamp: expect.any(Number), + type: 'http', + }), + ]), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.yml b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.yml new file mode 100644 index 0000000000..b453035f52 --- /dev/null +++ b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.yml @@ -0,0 +1,30 @@ +appId: io.sentry.reactnative.sample +--- +- launchApp: + # We expect cold start + clearState: true + stopApp: true + arguments: + isE2ETest: true + +# For unknown reasons tapOn: "Performance" does not work on iOS +- tapOn: + id: "performance-tab-icon" +- tapOn: "Open Spaceflight News" + +- scrollUntilVisible: + element: "Load More Articles" +# On iOS the visibility is resolved when the button only peaks from the bottom tabs +# this causes Maestro to click the bottom tab instead of the button +# thus the extra scroll is needed to make the button visible +- scroll +- tapOn: "Load More Articles" +- scrollUntilVisible: + element: "Load More Articles" + +- tapOn: + id: "errors-tab-icon" + +# The tab keeps News Screen open, but the data are updated on the next visit +- tapOn: + id: "performance-tab-icon" diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts index 43bbf80991..d5fd3de84f 100644 --- a/samples/react-native/e2e/utils/mockedSentryServer.ts +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -18,6 +18,7 @@ export function createSentryServer({ port = 8961 } = {}): { close: () => Promise; start: () => void; getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope; + getAllEnvelopes: (predicate: (envelope: Envelope) => boolean) => Envelope[]; } { const nextRequestCallbacks: (typeof onNextRequestCallback)[] = []; let onNextRequestCallback: (request: RecordedRequest) => void = ( @@ -55,6 +56,12 @@ export function createSentryServer({ port = 8961 } = {}): { }); }); + const getAllEnvelopes = (predicate: (envelope: Envelope) => boolean) => { + return requests + .filter(request => request.envelope && predicate(request.envelope)) + .map(request => request.envelope); + }; + return { start: () => { server.listen(port); @@ -82,16 +89,14 @@ export function createSentryServer({ port = 8961 } = {}): { }); }, getEnvelope: (predicate: (envelope: Envelope) => boolean) => { - const envelope = requests.find( - request => request.envelope && predicate(request.envelope), - )?.envelope; - + const [envelope] = getAllEnvelopes(predicate); if (!envelope) { throw new Error('Envelope not found'); } return envelope; }, + getAllEnvelopes, }; } @@ -131,6 +136,22 @@ export function containingTransactionWithName(name: string) { ); } +export function takeSecond(predicate: (envelope: Envelope) => boolean) { + const take = 2; + let counter = 0; + return (envelope: Envelope) => { + if (predicate(envelope)) { + counter++; + } + + if (counter === take) { + return true; + } + + return false; + }; +} + export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event { return typeof itemBody === 'object' && 'event_id' in itemBody; } diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 63effaeba6..e1ea24e8ec 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -52,7 +52,7 @@ const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios'; const reactNavigationIntegration = Sentry.reactNavigationIntegration({ routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms enableTimeToInitialDisplay: isMobileOs, - ignoreEmptyBackNavigationTransactions: true, + ignoreEmptyBackNavigationTransactions: false, enableTimeToInitialDisplayForPreloadedRoutes: true, }); @@ -144,7 +144,7 @@ Sentry.init({ // otherwise they will not work. // release: 'myapp@1.2.3+1', // dist: `1`, - profilesSampleRate: 1.0, + // profilesSampleRate: 1.0, replaysSessionSampleRate: 1.0, replaysOnErrorSampleRate: 1.0, spotlight: true, diff --git a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx index 530d8724d1..6aaa9d1650 100644 --- a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx +++ b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx @@ -6,6 +6,7 @@ import axios from 'axios'; import { ArticleCard } from '../components/ArticleCard'; import type { Article } from '../types/api'; import { useFocusEffect } from '@react-navigation/native'; +import * as Sentry from '@sentry/react-native'; const ITEMS_PER_PAGE = 2; // Small limit to create more spans const AUTO_LOAD_LIMIT = 1; // One auto load at the end of the list then shows button @@ -18,6 +19,7 @@ export default function NewsScreen() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [autoLoadCount, setAutoLoadCount] = useState(0); + // const [recordTimeToFullDisplay, setRecordTimeToFullDisplay] = useState(false); const fetchArticles = async (pageNumber: number, refresh = false) => { try { @@ -47,10 +49,24 @@ export default function NewsScreen() { useFocusEffect( useCallback(() => { + if (articles.length) { + console.log('Articles are already loaded'); + return; + } + fetchArticles(1, true); - }, []) + }, [articles]) ); + // useFocusEffect( + // useCallback(() => { + // setRecordTimeToFullDisplay(articles.length > 0); + // return () => { + // setRecordTimeToFullDisplay(false); + // }; + // }, [articles]) + // ); + const handleLoadMore = () => { if (!loading && hasMore) { setPage((prev) => prev + 1); @@ -106,6 +122,7 @@ export default function NewsScreen() { return ( + {/* */} } diff --git a/samples/react-native/src/components/ArticleCard.tsx b/samples/react-native/src/components/ArticleCard.tsx index 4d63abec1b..ac684ca481 100644 --- a/samples/react-native/src/components/ArticleCard.tsx +++ b/samples/react-native/src/components/ArticleCard.tsx @@ -2,14 +2,18 @@ import React from 'react'; import { View, Text, Image, StyleSheet, Pressable } from 'react-native'; import type { Article } from '../types/api'; import * as Sentry from '@sentry/react-native'; +import { useFocusEffect } from '@react-navigation/native'; + interface ArticleCardProps { article: Article; } +const TimeToFullDisplay = Sentry.createTimeToFullDisplay({useFocusEffect}); + export function ArticleCard({ article }: ArticleCardProps) { return ( - + From eee7cec7dcaaf0c3adf77c59922c3c684bac50e3 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 18 Mar 2025 18:27:48 +0100 Subject: [PATCH 04/33] add createTimeToInitialDisplay --- packages/core/src/js/index.ts | 1 + .../core/src/js/tracing/timetodisplay.tsx | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 22c7983dd5..3aa2bdb71d 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -80,6 +80,7 @@ export { startIdleSpan, getDefaultIdleNavigationSpanOptions, createTimeToFullDisplay, + createTimeToInitialDisplay, } from './tracing'; export type { TimeToDisplayProps } from './tracing'; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index e0b8229b2e..ee5dc5c21e 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -305,6 +305,27 @@ export function createTimeToFullDisplay({ * `@react-navigation/native` useFocusEffect hook. */ useFocusEffect: (callback: () => void) => void +}) { + return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay }); +} + +export function createTimeToInitialDisplay({ + useFocusEffect, +}: { + useFocusEffect: (callback: () => void) => void +}) { + return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay }); +} + +function createTimeToDisplay({ + useFocusEffect, + Component, +}: { + /** + * `@react-navigation/native` useFocusEffect hook. + */ + useFocusEffect: (callback: () => void) => void; + Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay; }) { return (props: TimeToDisplayProps) => { const [focused, setFocused] = useState(false); @@ -316,6 +337,6 @@ export function createTimeToFullDisplay({ }; }); - return ; + return ; }; } From a33f929c5f596eaed653a049df64e9db8608f338 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 18 Mar 2025 19:40:21 +0100 Subject: [PATCH 05/33] add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccfda406e..25922a0cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ }); ``` +- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665)) + ### Fixes - Considers the `SENTRY_DISABLE_AUTO_UPLOAD` and `SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD` environment variables in the configuration of the Sentry Android Gradle Plugin for Expo plugin ([#4583](https://github.com/getsentry/sentry-react-native/pull/4583)) From ceedad91a31852d4401cb2aaeebb7140ace1c368 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 19 Mar 2025 13:25:44 +0100 Subject: [PATCH 06/33] wip!: Add TimeToDisplay integration to force fetch the data --- .../io/sentry/react/RNSentryModuleImpl.java | 8 ++ .../react/RNSentryOnDrawReporterManager.java | 92 +++++++++--------- .../sentry/react/RNSentryTimeToDisplay.java | 19 ++++ .../java/io/sentry/react/RNSentryModule.java | 5 + packages/core/ios/RNSentryOnDrawReporter.h | 2 +- packages/core/ios/RNSentryOnDrawReporter.m | 14 +-- packages/core/ios/RNSentryTimeToDisplay.h | 3 + packages/core/ios/RNSentryTimeToDisplay.m | 24 +++++ packages/core/src/js/NativeRNSentry.ts | 1 + packages/core/src/js/integrations/default.ts | 2 + packages/core/src/js/integrations/exports.ts | 1 + .../js/tracing/integrations/timeToDisplay.ts | 93 +++++++++++++++++++ packages/core/src/js/tracing/ops.ts | 3 + .../core/src/js/tracing/timetodisplay.tsx | 26 ++---- .../js/tracing/timetodisplaynative.types.ts | 2 +- packages/core/src/js/wrapper.ts | 14 +++ 16 files changed, 231 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/js/tracing/integrations/timeToDisplay.ts diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index edefee7730..25bffb028d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -717,6 +717,14 @@ public void clearBreadcrumbs() { }); } + public void popTimeToDisplayFor(String screenId, Promise promise) { + if (screenId != null) { + promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId)); + } else { + promise.resolve(RNSentryTimeToDisplay.popAnonymousTimeToDisplay()); + } + } + public void setExtra(String key, String extra) { if (key == null || extra == null) { logger.log( diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java index a412ab51b5..b5f96d6d9f 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java @@ -57,12 +57,9 @@ public void setFullDisplay(RNSentryOnDrawReporterView view, boolean fullDisplay) view.setFullDisplay(fullDisplay); } - public Map getExportedCustomBubblingEventTypeConstants() { - return MapBuilder.builder() - .put( - "onDrawNextFrameView", - MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onDrawNextFrame"))) - .build(); + @ReactProp(name = "parentSpanId") + public void setParentSpanId(RNSentryOnDrawReporterView view, String parentSpanId) { + view.setParentSpanId(parentSpanId); } public static class RNSentryOnDrawReporterView extends View { @@ -71,16 +68,16 @@ public static class RNSentryOnDrawReporterView extends View { private final @Nullable ReactApplicationContext reactContext; private final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); - private final @Nullable Runnable emitInitialDisplayEvent; - private final @Nullable Runnable emitFullDisplayEvent; private final @Nullable BuildInfoProvider buildInfo; + private boolean isInitialDisplay = false; + private boolean isFullDisplay = false; + private @Nullable String parentSpanId = null; + public RNSentryOnDrawReporterView(@NotNull Context context) { super(context); reactContext = null; buildInfo = null; - emitInitialDisplayEvent = null; - emitFullDisplayEvent = null; } public RNSentryOnDrawReporterView( @@ -88,35 +85,37 @@ public RNSentryOnDrawReporterView( super(context); reactContext = context; buildInfo = buildInfoProvider; - emitInitialDisplayEvent = () -> emitDisplayEvent("initialDisplay"); - emitFullDisplayEvent = () -> emitDisplayEvent("fullDisplay"); } public void setFullDisplay(boolean fullDisplay) { - if (!fullDisplay) { - return; - } - - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); - registerForNextDraw(emitFullDisplayEvent); + isFullDisplay = fullDisplay; + registerForNextDraw(); } public void setInitialDisplay(boolean initialDisplay) { - if (!initialDisplay) { - return; - } + isInitialDisplay = initialDisplay; + registerForNextDraw(); + } - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); - registerForNextDraw(emitInitialDisplayEvent); + public void setParentSpanId(@Nullable String parentSpanId) { + this.parentSpanId = parentSpanId; + registerForNextDraw(); } - private void registerForNextDraw(@Nullable Runnable emitter) { - if (emitter == null) { - logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Won't emit next frame drawn event, emitter is null."); + private void registerForNextDraw() { + if (parentSpanId == null) { + return; + } + + if (isInitialDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); + } else if (isFullDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); + } else { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Not ready, missing displayType prop."); return; } + if (buildInfo == null) { logger.log( SentryLevel.ERROR, @@ -138,26 +137,23 @@ private void registerForNextDraw(@Nullable Runnable emitter) { return; } - FirstDrawDoneListener.registerForNextDraw(activity, emitter, buildInfo); - } - - private void emitDisplayEvent(String type) { - final SentryDate endDate = dateProvider.now(); - - WritableMap event = Arguments.createMap(); - event.putString("type", type); - event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9); - - if (reactContext == null) { - logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Recorded next frame draw but can't emit the event, reactContext is" - + " null."); - return; - } - reactContext - .getJSModule(RCTEventEmitter.class) - .receiveEvent(getId(), "onDrawNextFrameView", event); + FirstDrawDoneListener.registerForNextDraw(activity, () -> { + final Double now = dateProvider.now().nanoTimestamp() / 1e9; + if (parentSpanId == null) { + logger.log( + SentryLevel.ERROR, + "[TimeToDisplay] parentSpanId removed before frame was rendered."); + return; + } + + if (isInitialDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor("ttid-" + parentSpanId, now); + } else if (isFullDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor("ttfd-" + parentSpanId, now); + } else { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] display type removed before frame was rendered."); + } + }, buildInfo); } } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java index b6fab45492..398eea3e8a 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -6,11 +6,30 @@ import com.facebook.react.bridge.Promise; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; +import java.util.LinkedHashMap; +import java.util.Map; public final class RNSentryTimeToDisplay { private RNSentryTimeToDisplay() {} + private static final int ENTRIES_MAX_SIZE = 50; + private static final Map screenIdToRenderDuration = + new LinkedHashMap<>(ENTRIES_MAX_SIZE + 1, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > ENTRIES_MAX_SIZE; + } + }; + + public static Double popTimeToDisplayFor(String screenId) { + return screenIdToRenderDuration.remove(screenId); + } + + public static void putTimeToDisplayFor(String screenId, Double value) { + screenIdToRenderDuration.put(screenId, value); + } + public static void getTimeToDisplay(Promise promise, SentryDateProvider dateProvider) { Looper mainLooper = Looper.getMainLooper(); diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 92ef2c0614..4ab2241fa5 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -182,4 +182,9 @@ public void getNewScreenTimeToDisplay(Promise promise) { public void getDataFromUri(String uri, Promise promise) { this.impl.getDataFromUri(uri, promise); } + + @Override + public void popTimeToDisplayFor(String key, Promise promise) { + this.impl.popTimeToDisplayFor(key, promise); + } } diff --git a/packages/core/ios/RNSentryOnDrawReporter.h b/packages/core/ios/RNSentryOnDrawReporter.h index 1cf9fb6245..0fe0b2542c 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.h +++ b/packages/core/ios/RNSentryOnDrawReporter.h @@ -13,9 +13,9 @@ @interface RNSentryOnDrawReporterView : UIView @property (nonatomic, strong) RNSentryFramesTrackerListener *framesListener; -@property (nonatomic, copy) RCTBubblingEventBlock onDrawNextFrame; @property (nonatomic) bool fullDisplay; @property (nonatomic) bool initialDisplay; +@property (nonatomic, copy) NSString *parentSpanId; @property (nonatomic, weak) RNSentryOnDrawReporter *delegate; @end diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m index d8266c73a5..4112aa7382 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.m +++ b/packages/core/ios/RNSentryOnDrawReporter.m @@ -7,9 +7,9 @@ @implementation RNSentryOnDrawReporter RCT_EXPORT_MODULE(RNSentryOnDrawReporter) -RCT_EXPORT_VIEW_PROPERTY(onDrawNextFrame, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(initialDisplay, BOOL) RCT_EXPORT_VIEW_PROPERTY(fullDisplay, BOOL) +RCT_EXPORT_VIEW_PROPERTY(parentSpanId, BOOL) - (UIView *)view { @@ -27,18 +27,12 @@ - (instancetype)init if (self) { RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { if (self->_fullDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"fullDisplay" - }); + RNSentryTimeToDisplay.putTimeToDisplayFor([@"ttfd-" stringByAppendingString:self->_parentSpanId], newFrameTimestampInSeconds); return; } if (self->_initialDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"initialDisplay" - }); + RNSentryTimeToDisplay.putTimeToDisplayFor([@"ttid-" stringByAppendingString:self->_parentSpanId], newFrameTimestampInSeconds); return; } }; @@ -51,7 +45,7 @@ - (instancetype)init - (void)didSetProps:(NSArray *)changedProps { - if (_fullDisplay || _initialDisplay) { + if ((_fullDisplay || _initialDisplay) && [_parentSpanId isKindOfClass:[NSString class]]) { [_framesListener startListening]; } } diff --git a/packages/core/ios/RNSentryTimeToDisplay.h b/packages/core/ios/RNSentryTimeToDisplay.h index fbb468cb23..d1b10065bc 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.h +++ b/packages/core/ios/RNSentryTimeToDisplay.h @@ -2,6 +2,9 @@ @interface RNSentryTimeToDisplay : NSObject ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId; ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value; + - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback; @end diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m index 9404cf5088..d4312b6143 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.m +++ b/packages/core/ios/RNSentryTimeToDisplay.m @@ -7,6 +7,30 @@ @implementation RNSentryTimeToDisplay { RCTResponseSenderBlock resolveBlock; } +static const int ENTRIES_MAX_SIZE = 50; +static NSMutableDictionary *screenIdToRenderDuration; + ++ (void)initialize { + if (self == [RNSentryTimeToDisplay class]) { + screenIdToRenderDuration = [[NSMutableDictionary alloc] init]; + } +} + ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId { + NSNumber *value = screenIdToRenderDuration[screenId]; + [screenIdToRenderDuration removeObjectForKey:screenId]; + return value; +} + ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value { + // Remove oldest entry if at max size + if (screenIdToRenderDuration.count >= ENTRIES_MAX_SIZE) { + NSString *firstKey = screenIdToRenderDuration.allKeys.firstObject; + [screenIdToRenderDuration removeObjectForKey:firstKey]; + } + screenIdToRenderDuration[screenId] = value; +} + // Rename requestAnimationFrame to getTimeToDisplay - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback { diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 125dc3b082..cc69f081c3 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -49,6 +49,7 @@ export interface Spec extends TurboModule { getCurrentReplayId(): string | undefined | null; crashedLastRun(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 71ac905afb..a10dd18208 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -33,6 +33,7 @@ import { sdkInfoIntegration, spotlightIntegration, stallTrackingIntegration, + timeToDisplayIntegration, userInteractionIntegration, viewHierarchyIntegration, } from './exports'; @@ -115,6 +116,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(appRegistryIntegration()); integrations.push(reactNativeTracingIntegration()); } + integrations.push(timeToDisplayIntegration()); if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 695b323c38..6d44b0b6ad 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -21,6 +21,7 @@ export { stallTrackingIntegration } from '../tracing/integrations/stalltracking' export { userInteractionIntegration } from '../tracing/integrations/userInteraction'; export { createReactNativeRewriteFrames } from './rewriteframes'; export { appRegistryIntegration } from './appRegistry'; +export { timeToDisplayIntegration } from '../tracing/integrations/timetodisplay'; export { breadcrumbsIntegration, diff --git a/packages/core/src/js/tracing/integrations/timeToDisplay.ts b/packages/core/src/js/tracing/integrations/timeToDisplay.ts new file mode 100644 index 0000000000..1b5a2b15c4 --- /dev/null +++ b/packages/core/src/js/tracing/integrations/timeToDisplay.ts @@ -0,0 +1,93 @@ +import type { Integration, SpanJSON } from '@sentry/core'; +import { logger } from '@sentry/core'; +import { NATIVE } from '../../wrapper'; +import { createSpanJSON } from '../utils'; +import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; +import { UI_LOAD_INITIAL_DISPLAY, UI_LOAD_FULL_DISPLAY } from '../ops'; +export const INTEGRATION_NAME = 'TimeToDisplay'; + +export const timeToDisplayIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + processEvent: async event => { + if (event.type !== 'transaction') { + // TimeToDisplay data is only relevant for transactions + return event; + } + + const rootSpanId = event.contexts.trace.span_id; + if (!rootSpanId) { + logger.warn(`[${INTEGRATION_NAME}] No root span id found in transaction.`); + return event; + } + + const transactionStartTimestampSeconds = event.start_timestamp; + if (!transactionStartTimestampSeconds) { + // This should never happen + logger.warn(`[${INTEGRATION_NAME}] No transaction start timestamp found in transaction.`); + return event; + } + + event.spans = event.spans || []; + + const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); + let ttidSpan: SpanJSON | undefined; + if (ttidEndTimestampSeconds) { + ttidSpan = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); + if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { + ttidSpan.status = 'ok'; + ttidSpan.timestamp = ttidEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); + } else { + ttidSpan = createSpanJSON({ + op: UI_LOAD_INITIAL_DISPLAY, + description: 'NEW Time To Initial Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttidEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + // TODO: Add data + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan); + event.spans?.push(ttidSpan); + } + } + + // TODO: Should we trim it to 30s a.k.a max timeout? + const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + let ttfdSpan: SpanJSON | undefined; + if (ttfdEndTimestampSeconds) { + ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { + ttfdSpan.status = 'ok'; + ttfdSpan.timestamp = ttfdEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); + } else { + ttfdSpan = createSpanJSON({ + op: UI_LOAD_FULL_DISPLAY, + description: 'NEW Time To Full Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: + ttfdEndTimestampSeconds < ttidEndTimestampSeconds ? ttidEndTimestampSeconds : ttfdEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + // TODO: Add data + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan); + event.spans?.push(ttfdSpan); + } + } + + const newTransactionEndTimestampSeconds = Math.max( + ttidSpan?.timestamp ?? -1, + ttfdSpan?.timestamp ?? -1, + event.timestamp ?? -1, + ); + if (newTransactionEndTimestampSeconds !== -1) { + event.timestamp = newTransactionEndTimestampSeconds; + } + + return event; + }, + }; +}; diff --git a/packages/core/src/js/tracing/ops.ts b/packages/core/src/js/tracing/ops.ts index 0f574d89b9..79c7c239b1 100644 --- a/packages/core/src/js/tracing/ops.ts +++ b/packages/core/src/js/tracing/ops.ts @@ -7,3 +7,6 @@ export const UI_ACTION_TOUCH = 'ui.action.touch'; export const APP_START_COLD = 'app.start.cold'; export const APP_START_WARM = 'app.start.warm'; + +export const UI_LOAD_INITIAL_DISPLAY = 'ui.load.initial_display'; +export const UI_LOAD_FULL_DISPLAY = 'ui.load.full_display'; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index ee5dc5c21e..8f93954700 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -37,10 +37,10 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem const activeSpan = getActiveSpan(); if (activeSpan) { manualInitialDisplaySpans.set(activeSpan, true); - startTimeToInitialDisplaySpan(); } - return {props.children}; + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } /** @@ -51,14 +51,16 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem * */ export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement { - startTimeToFullDisplaySpan(); - return {props.children}; + const activeSpan = getActiveSpan(); + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } function TimeToDisplay(props: { children?: React.ReactNode; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; }): React.ReactElement { const RNSentryOnDrawReporter = getRNSentryOnDrawReporter(); const isNewArchitecture = isTurboModuleEnabled(); @@ -72,14 +74,12 @@ function TimeToDisplay(props: { }, 0); } - const onDraw = (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void => onDrawNextFrame(event); - return ( <> + fullDisplay={props.fullDisplay} + parentSpanId={props.parentSpanId} /> {props.children} ); @@ -197,16 +197,6 @@ export function startTimeToFullDisplaySpan( return fullDisplaySpan; } -function onDrawNextFrame(event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void { - logger.debug(`[TimeToDisplay] onDrawNextFrame: ${JSON.stringify(event.nativeEvent)}`); - if (event.nativeEvent.type === 'fullDisplay') { - return updateFullDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } - if (event.nativeEvent.type === 'initialDisplay') { - return updateInitialDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } -} - /** * */ diff --git a/packages/core/src/js/tracing/timetodisplaynative.types.ts b/packages/core/src/js/tracing/timetodisplaynative.types.ts index 85fbf5b4a2..ce6c90fe68 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.types.ts +++ b/packages/core/src/js/tracing/timetodisplaynative.types.ts @@ -5,7 +5,7 @@ export interface RNSentryOnDrawNextFrameEvent { export interface RNSentryOnDrawReporterProps { children?: React.ReactNode; - onDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 6ec2f84643..dcbd8d38cf 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -122,6 +122,7 @@ interface SentryNativeWrapper { getNewScreenTimeToDisplay(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; } const EOL = utf8ToBytes('\n'); @@ -717,6 +718,19 @@ export const NATIVE: SentryNativeWrapper = { } }, + popTimeToDisplayFor(key: string): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + try { + return RNSentry.popTimeToDisplayFor(key); + } catch (error) { + logger.error('Error:', error); + return null; + } + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. From 64fb9cc73cbf80472c0c3eef876e75992dd6362e Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Wed, 19 Mar 2025 15:18:19 +0100 Subject: [PATCH 07/33] add ios implementation and end timestamp align --- packages/core/ios/RNSentry.mm | 8 ++++++++ packages/core/ios/RNSentryOnDrawReporter.m | 18 +++++++++++++----- .../js/tracing/integrations/timeToDisplay.ts | 16 +++++++++------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index de7f82be35..ed8822d614 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -961,4 +961,12 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys [_timeToDisplay getTimeToDisplay:resolve]; } +RCT_EXPORT_METHOD(popTimeToDisplayFor + : (NSString*)key resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + resolve([RNSentryTimeToDisplay popTimeToDisplayFor:key]); +} + @end diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m index 4112aa7382..d06eeec855 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.m +++ b/packages/core/ios/RNSentryOnDrawReporter.m @@ -1,4 +1,5 @@ #import "RNSentryOnDrawReporter.h" +#import "RNSentryTimeToDisplay.h" #if SENTRY_HAS_UIKIT @@ -9,7 +10,7 @@ @implementation RNSentryOnDrawReporter RCT_EXPORT_MODULE(RNSentryOnDrawReporter) RCT_EXPORT_VIEW_PROPERTY(initialDisplay, BOOL) RCT_EXPORT_VIEW_PROPERTY(fullDisplay, BOOL) -RCT_EXPORT_VIEW_PROPERTY(parentSpanId, BOOL) +RCT_EXPORT_VIEW_PROPERTY(parentSpanId, NSString) - (UIView *)view { @@ -19,20 +20,24 @@ - (UIView *)view @end -@implementation RNSentryOnDrawReporterView +@implementation RNSentryOnDrawReporterView { + BOOL isListening; +} - (instancetype)init { self = [super init]; if (self) { RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { + self->isListening = NO; + if (self->_fullDisplay) { - RNSentryTimeToDisplay.putTimeToDisplayFor([@"ttfd-" stringByAppendingString:self->_parentSpanId], newFrameTimestampInSeconds); + [RNSentryTimeToDisplay putTimeToDisplayFor: [@"ttfd-" stringByAppendingString:self->_parentSpanId] value: newFrameTimestampInSeconds]; return; } if (self->_initialDisplay) { - RNSentryTimeToDisplay.putTimeToDisplayFor([@"ttid-" stringByAppendingString:self->_parentSpanId], newFrameTimestampInSeconds); + [RNSentryTimeToDisplay putTimeToDisplayFor: [@"ttid-" stringByAppendingString:self->_parentSpanId] value: newFrameTimestampInSeconds]; return; } }; @@ -46,7 +51,10 @@ - (instancetype)init - (void)didSetProps:(NSArray *)changedProps { if ((_fullDisplay || _initialDisplay) && [_parentSpanId isKindOfClass:[NSString class]]) { - [_framesListener startListening]; + if (!isListening) { + [_framesListener startListening]; + isListening = YES; + } } } diff --git a/packages/core/src/js/tracing/integrations/timeToDisplay.ts b/packages/core/src/js/tracing/integrations/timeToDisplay.ts index 1b5a2b15c4..7f9b2e3b86 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplay.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplay.ts @@ -31,9 +31,8 @@ export const timeToDisplayIntegration = (): Integration => { event.spans = event.spans || []; const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); - let ttidSpan: SpanJSON | undefined; + let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); if (ttidEndTimestampSeconds) { - ttidSpan = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { ttidSpan.status = 'ok'; ttidSpan.timestamp = ttidEndTimestampSeconds; @@ -56,19 +55,22 @@ export const timeToDisplayIntegration = (): Integration => { // TODO: Should we trim it to 30s a.k.a max timeout? const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); let ttfdSpan: SpanJSON | undefined; - if (ttfdEndTimestampSeconds) { + if (ttfdEndTimestampSeconds && ttidSpan) { ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + const ttfdAdjustedEndTimestampSeconds = + ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp + ? ttidSpan.timestamp + : ttfdEndTimestampSeconds; if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { ttfdSpan.status = 'ok'; - ttfdSpan.timestamp = ttfdEndTimestampSeconds; + ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); } else { ttfdSpan = createSpanJSON({ op: UI_LOAD_FULL_DISPLAY, - description: 'NEW Time To Full Display', + description: 'Time To Full Display', start_timestamp: transactionStartTimestampSeconds, - timestamp: - ttfdEndTimestampSeconds < ttidEndTimestampSeconds ? ttidEndTimestampSeconds : ttfdEndTimestampSeconds, + timestamp: ttfdAdjustedEndTimestampSeconds, origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, parent_span_id: rootSpanId, // TODO: Add data From 6c66f291e62cc22977c6ba7307e939012a0981d3 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 20 Mar 2025 13:02:19 +0100 Subject: [PATCH 08/33] Add `popTimeToDisplayFor` mock function to NATIVE interface for testing --- packages/core/test/mockWrapper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index fe6a611394..d7f8925676 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -60,6 +60,7 @@ const NATIVE: MockInterface = { crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), getDataFromUri: jest.fn(), + popTimeToDisplayFor: jest.fn().mockResolvedValue(42), }; NATIVE.isNativeAvailable.mockReturnValue(true); From a00860e27101bf926e1097c960f2bc5a9c9d0d72 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Thu, 20 Mar 2025 13:37:24 +0100 Subject: [PATCH 09/33] fix --- .../src/main/java/io/sentry/react/RNSentryModuleImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 25bffb028d..f9e8069cb4 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -721,7 +721,7 @@ public void popTimeToDisplayFor(String screenId, Promise promise) { if (screenId != null) { promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId)); } else { - promise.resolve(RNSentryTimeToDisplay.popAnonymousTimeToDisplay()); + promise.resolve(null); } } From 7c2dad9194f4c2eccab8806c863116ddd0e753d5 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:06:20 +0100 Subject: [PATCH 10/33] fix lint useFocus --- .../core/src/js/tracing/timetodisplay.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 8f93954700..fb3032ca75 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -1,13 +1,12 @@ import type { Span,StartSpanOptions } from '@sentry/core'; -import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, uuid4 } from '@sentry/core'; +import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core'; import * as React from 'react'; +import { useState } from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; -import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; -import { useState } from 'react'; let nativeComponentMissingLogged = false; @@ -288,6 +287,9 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl setSpanDurationAsMeasurement('time_to_full_display', span); } +/** + * Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused. + */ export function createTimeToFullDisplay({ useFocusEffect, }: { @@ -295,15 +297,18 @@ export function createTimeToFullDisplay({ * `@react-navigation/native` useFocusEffect hook. */ useFocusEffect: (callback: () => void) => void -}) { +}): React.ComponentType { return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay }); } +/** + * Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused. + */ export function createTimeToInitialDisplay({ useFocusEffect, }: { useFocusEffect: (callback: () => void) => void -}) { +}): React.ComponentType { return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay }); } @@ -316,8 +321,8 @@ function createTimeToDisplay({ */ useFocusEffect: (callback: () => void) => void; Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay; -}) { - return (props: TimeToDisplayProps) => { +}): React.ComponentType { + const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => { const [focused, setFocused] = useState(false); useFocusEffect(() => { @@ -329,4 +334,7 @@ function createTimeToDisplay({ return ; }; + + TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`; + return TimeToDisplayWrapper; } From 3d745b1819519743a36e0131c62233c8d112ee54 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:13:36 +0100 Subject: [PATCH 11/33] add time to initial display tests --- .../js/tracing/integrations/timeToDisplay.ts | 169 ++++++--- .../core/src/js/tracing/timetodisplay.tsx | 4 + packages/core/test/mockWrapper.ts | 3 +- packages/core/test/testutils.ts | 4 + .../tracing/mockedtimetodisplaynative.tsx | 31 +- .../tracing/reactnavigation.ttid.test.tsx | 34 +- .../core/test/tracing/timetodisplay.test.tsx | 320 +++++++----------- 7 files changed, 298 insertions(+), 267 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplay.ts b/packages/core/src/js/tracing/integrations/timeToDisplay.ts index 7f9b2e3b86..0efe086b80 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplay.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplay.ts @@ -1,11 +1,16 @@ -import type { Integration, SpanJSON } from '@sentry/core'; +import type { Event, Integration, SpanJSON } from '@sentry/core'; import { logger } from '@sentry/core'; + import { NATIVE } from '../../wrapper'; -import { createSpanJSON } from '../utils'; +import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; -import { UI_LOAD_INITIAL_DISPLAY, UI_LOAD_FULL_DISPLAY } from '../ops'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span'; +import { createSpanJSON } from '../utils'; export const INTEGRATION_NAME = 'TimeToDisplay'; +const TIME_TO_DISPLAY_TIMEOUT_MS = 30_000; +const isDeadlineExceeded = (durationMs: number): boolean => durationMs > TIME_TO_DISPLAY_TIMEOUT_MS; + export const timeToDisplayIntegration = (): Integration => { return { name: INTEGRATION_NAME, @@ -29,54 +34,27 @@ export const timeToDisplayIntegration = (): Integration => { } event.spans = event.spans || []; + event.measurements = event.measurements || {}; - const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); - let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); - if (ttidEndTimestampSeconds) { - if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { - ttidSpan.status = 'ok'; - ttidSpan.timestamp = ttidEndTimestampSeconds; - logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); - } else { - ttidSpan = createSpanJSON({ - op: UI_LOAD_INITIAL_DISPLAY, - description: 'NEW Time To Initial Display', - start_timestamp: transactionStartTimestampSeconds, - timestamp: ttidEndTimestampSeconds, - origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, - parent_span_id: rootSpanId, - // TODO: Add data - }); - logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan); - event.spans?.push(ttidSpan); - } + const ttidSpan = await addTimeToInitialDisplay({ event, rootSpanId, transactionStartTimestampSeconds }); + const ttfdSpan = await addTimeToFullDisplay({ event, rootSpanId, transactionStartTimestampSeconds, ttidSpan }); + + if (ttidSpan && ttidSpan.start_timestamp && ttidSpan.timestamp) { + event.measurements['time_to_initial_display'] = { + value: (ttidSpan.timestamp - ttidSpan.start_timestamp) * 1000, + unit: 'millisecond', + }; } - // TODO: Should we trim it to 30s a.k.a max timeout? - const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); - let ttfdSpan: SpanJSON | undefined; - if (ttfdEndTimestampSeconds && ttidSpan) { - ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); - const ttfdAdjustedEndTimestampSeconds = - ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp - ? ttidSpan.timestamp - : ttfdEndTimestampSeconds; - if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { - ttfdSpan.status = 'ok'; - ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; - logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); + if (ttfdSpan && ttfdSpan.start_timestamp && ttfdSpan.timestamp) { + const durationMs = (ttfdSpan.timestamp - ttfdSpan.start_timestamp) * 1000; + if (isDeadlineExceeded(durationMs)) { + event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display']; } else { - ttfdSpan = createSpanJSON({ - op: UI_LOAD_FULL_DISPLAY, - description: 'Time To Full Display', - start_timestamp: transactionStartTimestampSeconds, - timestamp: ttfdAdjustedEndTimestampSeconds, - origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, - parent_span_id: rootSpanId, - // TODO: Add data - }); - logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan); - event.spans?.push(ttfdSpan); + event.measurements['time_to_full_display'] = { + value: durationMs, + unit: 'millisecond', + }; } } @@ -93,3 +71,100 @@ export const timeToDisplayIntegration = (): Integration => { }, }; }; + +async function addTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; +}): Promise { + const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); + + let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); + + if (ttidSpan && (ttidSpan.status === undefined || ttidSpan.status === 'ok') && !ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] Ttid span already exists and is ok.`, ttidSpan); + return ttidSpan; + } + + if (!ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] No ttid end timestamp found for span ${rootSpanId}.`); + return undefined; + } + + if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { + ttidSpan.status = 'ok'; + ttidSpan.timestamp = ttidEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); + return ttidSpan; + } + + ttidSpan = createSpanJSON({ + op: UI_LOAD_INITIAL_DISPLAY, + description: 'Time To Initial Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttidEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan); + event.spans.push(ttidSpan); + return ttidSpan; +} + +async function addTimeToFullDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + ttidSpan, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; + ttidSpan: SpanJSON | undefined; +}): Promise { + const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + + if (!ttidSpan || !ttfdEndTimestampSeconds) { + return undefined; + } + + let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; + const ttfdIsBeforeTtid = ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; + if (ttfdIsBeforeTtid) { + ttfdAdjustedEndTimestampSeconds = ttidSpan.timestamp; + } + + const durationMs = (ttfdAdjustedEndTimestampSeconds - transactionStartTimestampSeconds) * 1000; + + if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { + ttfdSpan.status = 'ok'; + ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); + return ttfdSpan; + } + + ttfdSpan = createSpanJSON({ + status: isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok', + op: UI_LOAD_FULL_DISPLAY, + description: 'Time To Full Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttfdAdjustedEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan); + event.spans.push(ttfdSpan); + return ttfdSpan; +} diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index fb3032ca75..f33216b875 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -88,6 +88,8 @@ function TimeToDisplay(props: { * Starts a new span for the initial display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToInitialDisplaySpan( options?: Omit & { @@ -132,6 +134,8 @@ export function startTimeToInitialDisplaySpan( * Starts a new span for the full display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToFullDisplaySpan( options: Omit & { diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index d7f8925676..923a6becfc 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -60,7 +60,7 @@ const NATIVE: MockInterface = { crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), getDataFromUri: jest.fn(), - popTimeToDisplayFor: jest.fn().mockResolvedValue(42), + popTimeToDisplayFor: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -85,6 +85,7 @@ NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve NATIVE.captureReplay.mockResolvedValue(null); NATIVE.getCurrentReplayId.mockReturnValue(null); NATIVE.crashedLastRun.mockResolvedValue(false); +NATIVE.popTimeToDisplayFor.mockResolvedValue(null); export const getRNSentryModule = jest.fn(); diff --git a/packages/core/test/testutils.ts b/packages/core/test/testutils.ts index 76bdd990a6..bf0aa9f3d7 100644 --- a/packages/core/test/testutils.ts +++ b/packages/core/test/testutils.ts @@ -62,6 +62,10 @@ export const createMockTransport = (): MockInterface => { }; }; +export const nowInSeconds = (): number => { + return Date.now() / 1000; +}; + export const secondAgoTimestampMs = (): number => { return new Date(Date.now() - 1000).getTime(); }; diff --git a/packages/core/test/tracing/mockedtimetodisplaynative.tsx b/packages/core/test/tracing/mockedtimetodisplaynative.tsx index 14c78fc5e0..886ff1cad0 100644 --- a/packages/core/test/tracing/mockedtimetodisplaynative.tsx +++ b/packages/core/test/tracing/mockedtimetodisplaynative.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { View } from 'react-native'; -import type { RNSentryOnDrawNextFrameEvent, RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import type { RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import { NATIVE } from '../mockWrapper'; export let nativeComponentExists = true; @@ -9,18 +10,34 @@ export function setMockedNativeComponentExists(value: boolean): void { nativeComponentExists = value; } -export let mockedOnDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; +/** + * { + * [spanId]: timestampInSeconds, + * } + */ +export function mockRecordedTimeToDisplay({ ttid = {}, ttfd = {} }: { ttid?: Record, ttfd?: Record }): void { + NATIVE.popTimeToDisplayFor.mockImplementation((key: string) => { + if (key.startsWith('ttid-')) { + return Promise.resolve(ttid[key.substring(5)]); + } else if (key.startsWith('ttfd-')) { + return Promise.resolve(ttfd[key.substring(5)]); + } + return Promise.resolve(undefined); + }); +} + +let mockedProps: RNSentryOnDrawReporterProps[] = []; -export function emitNativeInitialDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'initialDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function getMockedOnDrawReportedProps(): RNSentryOnDrawReporterProps[] { + return mockedProps; } -export function emitNativeFullDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'fullDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function clearMockedOnDrawReportedProps(): void { + mockedProps = []; } function RNSentryOnDrawReporterMock(props: RNSentryOnDrawReporterProps): React.ReactElement { - mockedOnDrawNextFrame = props.onDrawNextFrame; + mockedProps.push(props); return ; } diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index f134b5b8f8..d960678854 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -1,5 +1,5 @@ import type { Scope, Span, SpanJSON, TransactionEvent, Transport } from '@sentry/core'; -import { timestampInSeconds } from '@sentry/core'; +import { getActiveSpan, spanToJSON, timestampInSeconds } from '@sentry/core'; import * as TestRenderer from '@testing-library/react-native' import * as React from "react"; @@ -21,9 +21,10 @@ import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; 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 { nowInSeconds, secondInFutureTimestampMs } from '../testutils'; import type { MockedSentryEventEmitterFallback } from '../utils/mockedSentryeventemitterfallback'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { mockRecordedTimeToDisplay } from './mockedtimetodisplaynative'; +// import { // mockRecordedFullDisplayAt, // mockRecordedInitialDisplayAt } from './mockedtimetodisplaynative'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -286,7 +287,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -356,7 +361,11 @@ describe('React Navigation - TTID', () => { mockedNavigation.navigateToNewScreen(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds() - 1, + }, + }); mockedEventEmitter.emitNewFrameEvent(); jest.runOnlyPendingTimers(); // Flush navigation transaction @@ -378,7 +387,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -481,7 +494,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); timeToDisplayComponent.update(); - emitNativeInitialDisplayEvent(manualInitialDisplayEndTimestampMs); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(getActiveSpan()!).span_id!]: manualInitialDisplayEndTimestampMs / 1_000, + }, + }); jest.runOnlyPendingTimers(); // Flush transaction @@ -515,7 +532,7 @@ describe('React Navigation - TTID', () => { // Initialized too late auto instrumentation finished before manual TestRenderer.render(); - emitNativeInitialDisplayEvent(secondInFutureTimestampMs()); + // mockRecordedInitialDisplayAt(secondInFutureTimestampMs()); jest.runOnlyPendingTimers(); // Flush transaction @@ -703,6 +720,7 @@ function initSentry(sut: ReturnType): integrations: [ sut, Sentry.reactNativeTracingIntegration(), + Sentry.timeToDisplayIntegration(), ], transport: () => ({ send: transportSendMock.mockResolvedValue({}), diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index bfdbd3baa5..eeb92f70f3 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -1,6 +1,9 @@ -import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, getSpanDescendants, logger , setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core'; +import { getCurrentScope, getGlobalScope, getIsolationScope, logger , setCurrentClient, spanToJSON, startSpanManual } from '@sentry/core'; jest.spyOn(logger, 'warn'); +import * as mockWrapper from '../mockWrapper'; +jest.mock('../../src/js/wrapper', () => mockWrapper); + import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); @@ -13,12 +16,15 @@ import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; import * as React from "react"; import * as TestRenderer from 'react-test-renderer'; +import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timetodisplay'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes'; +import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { nowInSeconds, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; + +const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; jest.useFakeTimers({advanceTimers: true}); @@ -26,6 +32,7 @@ describe('TimeToDisplay', () => { let client: TestClient; beforeEach(() => { + clearMockedOnDrawReportedProps(); getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -33,7 +40,13 @@ describe('TimeToDisplay', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, }); - client = new TestClient(options); + client = new TestClient({ + ...options, + integrations: [ + ...options.integrations, + timeToDisplayIntegration(), + ], + }); setCurrentClient(client); client.init(); }); @@ -43,20 +56,21 @@ describe('TimeToDisplay', () => { }); test('creates manual initial display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const testSpan = startTimeToInitialDisplaySpan(); + startTimeToInitialDisplaySpan(); TestRenderer.create(); - - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - - return [testSpan, activeSpan]; }, ); @@ -64,83 +78,33 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates manual full display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); - const testSpan = startTimeToFullDisplaySpan(); + startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); - - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates initial display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const renderer = TestRenderer.create(); - const testSpan = getInitialDisplaySpan(activeSpan); - - renderer.update(); - emitNativeInitialDisplayEvent(); - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates full display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - TestRenderer.create(); - emitNativeInitialDisplayEvent(); - - const renderer = TestRenderer.create(); - const testSpan = getFullDisplaySpan(getActiveSpan()); - - renderer.update(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [testSpan, activeSpan]; }, ); @@ -148,12 +112,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('does not create full display when initial display is missing', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -162,10 +127,13 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -175,11 +143,13 @@ describe('TimeToDisplay', () => { expectNoInitialDisplayMeasurementOnSpan(client.event!); expectNoFullDisplayMeasurementOnSpan(client.event!); - expectNoTimeToDisplaySpans(activeSpan); + expectNoTimeToDisplaySpans(client.event!); + + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates initial display for active span without initial display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -187,10 +157,13 @@ describe('TimeToDisplay', () => { (activeSpan: Span | undefined) => { TestRenderer.create(); - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -198,11 +171,12 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates full display for active span without full display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -212,13 +186,18 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -226,11 +205,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('cancels full display spans longer than 30s', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -240,23 +221,26 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - // native event is not emitted - jest.advanceTimersByTime(40_000); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds() + 40, + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); - expectDeadlineExceededFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectDeadlineExceededFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); @@ -267,120 +251,46 @@ describe('TimeToDisplay', () => { test('full display which ended before initial display is extended to initial display end', async () => { const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 10); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('full display which ended before but processed after initial display is extended to initial display end', async () => { - const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); - const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); + startTimeToInitialDisplaySpan(); + startTimeToFullDisplaySpan(); const timeToDisplayComponent = TestRenderer.create(<>); - timeToDisplayComponent.update(<>); - - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('consequent renders do not update display end', async () => { - const initialDisplayEndTimestampMs = secondInFutureTimestampMs(); - const fullDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeInitialDisplayEvent(fullDisplayEndTimestampMs + 10); timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 20); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: fullDisplayEndTimestampMs / 1_000, + }, + ttid: { + [spanToJSON(activeSpan!).span_id!]: initialDisplayEndTimestampMs / 1_000, + }, + }); activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectFinishedFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(fullDisplayEndTimestampMs / 1_000); + expect(getInitialDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); + expect(getFullDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); }); test('should not log a warning if native component exists and not in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(false); TestRenderer.create(); @@ -390,7 +300,6 @@ describe('TimeToDisplay', () => { }); test('should log a warning if in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(true); TestRenderer.create(); await jest.runOnlyPendingTimersAsync(); // Flush setTimeout. @@ -400,62 +309,65 @@ describe('TimeToDisplay', () => { }); }); -function getInitialDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.initial_display'); +function getInitialDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.initial_display'); } -function getFullDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.full_display'); +function getFullDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.full_display'); } -function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedInitialDisplaySpan(event: Event) { + expect(getInitialDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.initial_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Initial Display', op: 'ui.load.initial_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectFinishedFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectDeadlineExceededFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'deadline_exceeded', timestamp: expect.any(Number), })); } -function expectNoTimeToDisplaySpans(span?: Span) { - expect(getSpanDescendants(span!).map(spanToJSON)).toEqual(expect.not.arrayContaining([ +function expectNoTimeToDisplaySpans(event: Event) { + expect(event.spans).toEqual(expect.not.arrayContaining([ expect.objectContaining>({ op: 'ui.load.initial_display' }), expect.objectContaining>({ op: 'ui.load.full_display' }), ])); From c5048bd0e08a81cb101a8cf8870d79de7d766adb Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:19:21 +0100 Subject: [PATCH 12/33] add ttd integration tests --- packages/core/src/js/integrations/default.ts | 4 +++- packages/core/test/sdk.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index a10dd18208..54b4065ee5 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -116,7 +116,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(appRegistryIntegration()); integrations.push(reactNativeTracingIntegration()); } - integrations.push(timeToDisplayIntegration()); + if (hasTracingEnabled) { + integrations.push(timeToDisplayIntegration()); + } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index 330652b284..268dc742f2 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -743,6 +743,22 @@ describe('Tests the SDK functionality', () => { }); }); + describe('time to display integration', () => { + it('no integration when tracing disabled', () => { + init({}); + + expectNotIntegration('TimeToDisplay'); + }); + + it('integration added when tracing enabled', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('TimeToDisplay'); + }); + }); + it('adds spotlight integration with spotlight bool', () => { init({ spotlight: true, From 7a0950db30f4e92f011869842d05bfec1284af49 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:06:20 +0100 Subject: [PATCH 13/33] fix lint useFocus --- .../core/src/js/tracing/timetodisplay.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index ee5dc5c21e..23dfbebfb6 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -1,13 +1,12 @@ import type { Span,StartSpanOptions } from '@sentry/core'; -import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, uuid4 } from '@sentry/core'; +import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core'; import * as React from 'react'; +import { useState } from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; -import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; -import { useState } from 'react'; let nativeComponentMissingLogged = false; @@ -298,6 +297,9 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl setSpanDurationAsMeasurement('time_to_full_display', span); } +/** + * Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused. + */ export function createTimeToFullDisplay({ useFocusEffect, }: { @@ -305,15 +307,18 @@ export function createTimeToFullDisplay({ * `@react-navigation/native` useFocusEffect hook. */ useFocusEffect: (callback: () => void) => void -}) { +}): React.ComponentType { return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay }); } +/** + * Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused. + */ export function createTimeToInitialDisplay({ useFocusEffect, }: { useFocusEffect: (callback: () => void) => void -}) { +}): React.ComponentType { return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay }); } @@ -326,8 +331,8 @@ function createTimeToDisplay({ */ useFocusEffect: (callback: () => void) => void; Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay; -}) { - return (props: TimeToDisplayProps) => { +}): React.ComponentType { + const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => { const [focused, setFocused] = useState(false); useFocusEffect(() => { @@ -339,4 +344,7 @@ function createTimeToDisplay({ return ; }; + + TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`; + return TimeToDisplayWrapper; } From ce4d13fe3b6a8c78de8cd5a3c2bdcd40640e4327 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:22:39 +0100 Subject: [PATCH 14/33] fix lint --- packages/core/src/js/tracing/timetodisplay.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 23dfbebfb6..0c9c97d8ab 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; +import type { RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; let nativeComponentMissingLogged = false; From 3fa5860a76632b5951bef93595c17b684aca2e66 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:23:59 +0100 Subject: [PATCH 15/33] Revert "Merge branch 'kw-record-ttd-on-focus' into kw-force-fetch-ttd" This reverts commit 8f69e404b6eedc068588336a56090ac9e31025c7, reversing changes made to c5048bd0e08a81cb101a8cf8870d79de7d766adb. --- .vscode/launch.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index dee59e03e2..5555cc4e81 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,15 +5,14 @@ "name": "Jest Tests", "type": "node", "request": "launch", - "runtimeExecutable": "/Users/krystofwoldrich/.nvm/versions/node/v20.18.2/bin/node", - "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/packages/core/node_modules/jest/bin/jest.js"], - "args": ["--runInBand", "-t", "ttfd span duration and measurement should equal ttid from ttfd is called earlier than ttid"], - "cwd": "${workspaceRoot}/packages/core", + "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/jest/bin/jest.js"], + "args": ["--runInBand", "-t", ""], + "cwd": "${workspaceRoot}", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "outputCapture": "std", "sourceMaps": true, - "smartStep": false + "smartStep": true }, ] From 1764f2a284534c34a4e43b669f6130d9a85cc62e Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:26:34 +0100 Subject: [PATCH 16/33] fix file name --- packages/core/src/js/integrations/exports.ts | 2 +- packages/core/test/tracing/timetodisplay.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 6d44b0b6ad..8b190f3441 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -21,7 +21,7 @@ export { stallTrackingIntegration } from '../tracing/integrations/stalltracking' export { userInteractionIntegration } from '../tracing/integrations/userInteraction'; export { createReactNativeRewriteFrames } from './rewriteframes'; export { appRegistryIntegration } from './appRegistry'; -export { timeToDisplayIntegration } from '../tracing/integrations/timetodisplay'; +export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplay'; export { breadcrumbsIntegration, diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index eeb92f70f3..4da86b9b58 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -16,7 +16,7 @@ import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; import * as React from "react"; import * as TestRenderer from 'react-test-renderer'; -import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timetodisplay'; +import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplay'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; From d21477b94a23a1296385f711afa81269dcfb8eae Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 12:32:24 +0100 Subject: [PATCH 17/33] fix lint --- samples/react-native/src/Screens/SpaceflightNewsScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx index 6aaa9d1650..57e3efa645 100644 --- a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx +++ b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx @@ -6,7 +6,6 @@ import axios from 'axios'; import { ArticleCard } from '../components/ArticleCard'; import type { Article } from '../types/api'; import { useFocusEffect } from '@react-navigation/native'; -import * as Sentry from '@sentry/react-native'; const ITEMS_PER_PAGE = 2; // Small limit to create more spans const AUTO_LOAD_LIMIT = 1; // One auto load at the end of the list then shows button From 053139c77425d834afd79533381eec549615928c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 16:06:59 +0100 Subject: [PATCH 18/33] add android draw reported tests --- .../react/RNSentryOnDrawReporterTest.kt | 148 ++++++++++++++++++ .../react/RNSentryOnDrawReporterManager.java | 97 ++++++++---- .../sentry/react/RNSentryTimeToDisplay.java | 2 +- 3 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt new file mode 100644 index 0000000000..9c9922c758 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt @@ -0,0 +1,148 @@ +package io.sentry.react + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.facebook.react.bridge.ReactApplicationContext +import io.sentry.android.core.BuildInfoProvider +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryOnDrawReporterTest { + companion object { + private const val TTID_PREFIX = RNSentryOnDrawReporterManager.TTID_PREFIX + private const val TTFD_PREFIX = RNSentryOnDrawReporterManager.TTFD_PREFIX + private const val SPAN_ID = "test-span-id" + private const val NEW_SPAN_ID = "new-test-span-id" + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next full display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next initial display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + class TestRNSentryOnDrawReporterView( + context: Context, + reactContext: ReactApplicationContext, + buildInfo: BuildInfoProvider, + ) : RNSentryOnDrawReporterManager.RNSentryOnDrawReporterView(context, reactContext, buildInfo) { + override fun registerForNextDraw( + activity: Activity, + callback: Runnable, + buildInfo: BuildInfoProvider, + ) { + callback.run() + } + } + + private fun createTestRNSentryOnDrawReporterView(): TestRNSentryOnDrawReporterView = + TestRNSentryOnDrawReporterView(ApplicationProvider.getApplicationContext(), mockReactContext(), mockBuildInfo()) + + private fun mockReactContext(): ReactApplicationContext { + val reactContext = mock() + whenever(reactContext.getCurrentActivity()).thenReturn(mock()) + return reactContext + } + + private fun mockBuildInfo(): BuildInfoProvider = mock() +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java index b5f96d6d9f..37ae69cfd0 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java @@ -3,30 +3,28 @@ import android.app.Activity; import android.content.Context; import android.view.View; -import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.uimanager.events.RCTEventEmitter; import io.sentry.ILogger; -import io.sentry.SentryDate; import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; -import java.util.Map; +import java.util.Objects; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public class RNSentryOnDrawReporterManager extends SimpleViewManager { public static final String REACT_CLASS = "RNSentryOnDrawReporter"; + public static final String TTID_PREFIX = "ttid-"; + public static final String TTFD_PREFIX = "ttfd-"; private final @NotNull ReactApplicationContext mCallerContext; public RNSentryOnDrawReporterManager(ReactApplicationContext reactContext) { @@ -72,6 +70,7 @@ public static class RNSentryOnDrawReporterView extends View { private boolean isInitialDisplay = false; private boolean isFullDisplay = false; + private boolean spanIdUsed = false; private @Nullable String parentSpanId = null; public RNSentryOnDrawReporterView(@NotNull Context context) { @@ -87,25 +86,49 @@ public RNSentryOnDrawReporterView( buildInfo = buildInfoProvider; } - public void setFullDisplay(boolean fullDisplay) { - isFullDisplay = fullDisplay; - registerForNextDraw(); + @TestOnly + public RNSentryOnDrawReporterView( + @NotNull Context context, + @NotNull ReactApplicationContext reactContext, + @NotNull BuildInfoProvider buildInfoProvider) { + super(context); + this.reactContext = reactContext; + buildInfo = buildInfoProvider; } - public void setInitialDisplay(boolean initialDisplay) { - isInitialDisplay = initialDisplay; - registerForNextDraw(); + public void setFullDisplay(boolean newIsFullDisplay) { + if (newIsFullDisplay != isFullDisplay) { + isFullDisplay = newIsFullDisplay; + processPropsChanged(); + } } - public void setParentSpanId(@Nullable String parentSpanId) { - this.parentSpanId = parentSpanId; - registerForNextDraw(); + public void setInitialDisplay(boolean newIsInitialDisplay) { + if (newIsInitialDisplay != isInitialDisplay) { + isInitialDisplay = newIsInitialDisplay; + processPropsChanged(); + } } - private void registerForNextDraw() { + public void setParentSpanId(@Nullable String newParentSpanId) { + if (!Objects.equals(newParentSpanId, parentSpanId)) { + parentSpanId = newParentSpanId; + spanIdUsed = false; + processPropsChanged(); + } + } + + private void processPropsChanged() { if (parentSpanId == null) { return; } + if (spanIdUsed) { + logger.log( + SentryLevel.DEBUG, + "[TimeToDisplay] Already recorded time to display for spanId: " + parentSpanId); + return; + } + spanIdUsed = true; if (isInitialDisplay) { logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); @@ -137,23 +160,35 @@ private void registerForNextDraw() { return; } - FirstDrawDoneListener.registerForNextDraw(activity, () -> { - final Double now = dateProvider.now().nanoTimestamp() / 1e9; - if (parentSpanId == null) { - logger.log( + registerForNextDraw( + activity, + () -> { + final Double now = dateProvider.now().nanoTimestamp() / 1e9; + if (parentSpanId == null) { + logger.log( SentryLevel.ERROR, "[TimeToDisplay] parentSpanId removed before frame was rendered."); - return; - } - - if (isInitialDisplay) { - RNSentryTimeToDisplay.putTimeToDisplayFor("ttid-" + parentSpanId, now); - } else if (isFullDisplay) { - RNSentryTimeToDisplay.putTimeToDisplayFor("ttfd-" + parentSpanId, now); - } else { - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] display type removed before frame was rendered."); - } - }, buildInfo); + return; + } + + if (isInitialDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTID_PREFIX + parentSpanId, now); + } else if (isFullDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTFD_PREFIX + parentSpanId, now); + } else { + logger.log( + SentryLevel.DEBUG, + "[TimeToDisplay] display type removed before frame was rendered."); + } + }, + buildInfo); + } + + protected void registerForNextDraw( + final @NotNull Activity activity, + final @NotNull Runnable callback, + final @NotNull BuildInfoProvider buildInfo) { + FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo); } } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java index 398eea3e8a..c87590472f 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -15,7 +15,7 @@ private RNSentryTimeToDisplay() {} private static final int ENTRIES_MAX_SIZE = 50; private static final Map screenIdToRenderDuration = - new LinkedHashMap<>(ENTRIES_MAX_SIZE + 1, 0.75f, true) { + new LinkedHashMap(ENTRIES_MAX_SIZE + 1, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > ENTRIES_MAX_SIZE; From bda2845db7e9cb04669db64d59a0981256c93466 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 23:56:12 +0100 Subject: [PATCH 19/33] add ios tests --- .../project.pbxproj | 16 ++- ...RNSentryCocoaTesterTests-Bridging-Header.h | 2 + .../RNSentryOnDrawReporter+Test.h | 10 ++ .../RNSentryOnDrawReporter+Test.mm | 49 +++++++ .../RNSentryOnDrawReporterTests.m | 16 --- .../RNSentryOnDrawReporterTests.swift | 122 ++++++++++++++++++ packages/core/ios/RNSentry.mm | 2 +- .../core/ios/RNSentryFramesTrackerListener.h | 10 +- packages/core/ios/RNSentryOnDrawReporter.h | 6 +- packages/core/ios/RNSentryOnDrawReporter.m | 57 +++++--- packages/core/ios/RNSentryTimeToDisplay.m | 9 +- 11 files changed, 255 insertions(+), 44 deletions(-) create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm delete mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 48489ab216..94a3cdc4da 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -11,9 +11,10 @@ 3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3339C4802D6625570088EB3A /* RNSentryUserTests.mm */; }; 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; - 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */; }; + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; @@ -37,12 +38,15 @@ 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryReplayPostInitTests.swift; sourceTree = ""; }; 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; - 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryDependencyContainerTests.h; sourceTree = ""; }; 33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryDependencyContainer.h; path = ../ios/RNSentryDependencyContainer.h; sourceTree = ""; }; + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryOnDrawReporterTests.swift; sourceTree = ""; }; + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryOnDrawReporter+Test.h"; sourceTree = ""; }; + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNSentryOnDrawReporter+Test.mm"; sourceTree = ""; }; + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; }; 33F58ACF2977037D008F60EA /* RNSentryTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTests.mm; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E2321E7CFA55AB617247098E /* Pods-RNSentryCocoaTesterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.debug.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.debug.xcconfig"; sourceTree = ""; }; @@ -91,6 +95,9 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */, + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */, + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */, 3339C47F2D6625260088EB3A /* RNSentry+Test.h */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, @@ -101,7 +108,6 @@ 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */, 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */, - 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */, 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */, @@ -121,6 +127,7 @@ 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, @@ -244,10 +251,11 @@ files = ( AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, - 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */, 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index bc2bdd0304..a00ee1747c 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -3,7 +3,9 @@ // #import "RNSentryBreadcrumb.h" +#import "RNSentryOnDrawReporter+Test.h" #import "RNSentryReplay.h" #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" +#import "RNSentryTimeToDisplay.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h new file mode 100644 index 0000000000..2ef701d215 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h @@ -0,0 +1,10 @@ +#import "RNSentryOnDrawReporter.h" +#import + +@interface +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener; +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm new file mode 100644 index 0000000000..3aca532855 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm @@ -0,0 +1,49 @@ +#import "RNSentryOnDrawReporter+Test.h" + +@interface MockedListener : NSObject +@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; +@end + +@implementation MockedListener + +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent +{ + self = [super init]; + if (self) { + _emitNewFrameEvent = [emitNewFrameEvent copy]; + } + return self; +} + +- (void)startListening +{ + self.emitNewFrameEvent(@([[NSDate date] timeIntervalSince1970])); +} + +- (void)framesTrackerHasNewFrame:(nonnull NSDate *)newFrameDate +{ + NSLog(@"Not implemented in the test mock"); +} + +@end + +@implementation +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener +{ + return [[self alloc] initWithMockedListener]; +} + +- (instancetype)initWithMockedListener +{ + self = [super init]; + if (self) { + self.framesListener = + [[MockedListener alloc] initWithMockedListener:[self createEmitNewFrameEvent]]; + } + return self; +} + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m deleted file mode 100644 index 13de6a12c9..0000000000 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m +++ /dev/null @@ -1,16 +0,0 @@ -#import "RNSentryOnDrawReporter.h" -#import - -@interface RNSentryOnDrawReporterTests : XCTestCase - -@end - -@implementation RNSentryOnDrawReporterTests - -- (void)testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs -{ - RNSentryOnDrawReporterView *view = [[RNSentryOnDrawReporterView alloc] init]; - XCTAssertNotNil(view); -} - -@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift new file mode 100644 index 0000000000..ce3f37bb5c --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift @@ -0,0 +1,122 @@ +import Sentry +import XCTest + +final class RNSentryOnDrawReporterTests: XCTestCase { + private let ttidPrefix = "ttid-" + private let ttfdPrefix = "ttfd-" + private let spanId = "test-span-id" + private let newSpanId = "new-test-span-id" + + func testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs() { + let view = RNSentryOnDrawReporterView() + XCTAssertNotNil(view) + } + + func testWhenParentSpanIdAndTimeToFullDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdAndTimeToInitialDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextFullDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + newSpanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextInitialDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + newSpanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = true + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = true + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } +} diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index ed8822d614..38691c725b 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -962,7 +962,7 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys } RCT_EXPORT_METHOD(popTimeToDisplayFor - : (NSString*)key resolver + : (NSString *)key resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { diff --git a/packages/core/ios/RNSentryFramesTrackerListener.h b/packages/core/ios/RNSentryFramesTrackerListener.h index e0de09dfd9..627b3059f4 100644 --- a/packages/core/ios/RNSentryFramesTrackerListener.h +++ b/packages/core/ios/RNSentryFramesTrackerListener.h @@ -8,13 +8,17 @@ typedef void (^RNSentryEmitNewFrameEvent)(NSNumber *newFrameTimestampInSeconds); -@interface RNSentryFramesTrackerListener : NSObject +@protocol RNSentryFramesTrackerListenerProtocol + +- (void)startListening; + +@end + +@interface RNSentryFramesTrackerListener : NSObject - (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *)framesTracker andEventEmitter:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; -- (void)startListening; - @property (strong, nonatomic) SentryFramesTracker *framesTracker; @property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; diff --git a/packages/core/ios/RNSentryOnDrawReporter.h b/packages/core/ios/RNSentryOnDrawReporter.h index 0fe0b2542c..5c4083015d 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.h +++ b/packages/core/ios/RNSentryOnDrawReporter.h @@ -12,11 +12,15 @@ @interface RNSentryOnDrawReporterView : UIView -@property (nonatomic, strong) RNSentryFramesTrackerListener *framesListener; +@property (nonatomic, strong) id framesListener; @property (nonatomic) bool fullDisplay; @property (nonatomic) bool initialDisplay; +@property (nonatomic) bool spanIdUsed; @property (nonatomic, copy) NSString *parentSpanId; @property (nonatomic, weak) RNSentryOnDrawReporter *delegate; +@property (nonatomic) bool previousFullDisplay; +@property (nonatomic) bool previousInitialDisplay; +@property (nonatomic, copy) NSString *previousParentSpanId; @end diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m index d06eeec855..85b2ee98ec 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.m +++ b/packages/core/ios/RNSentryOnDrawReporter.m @@ -28,19 +28,8 @@ - (instancetype)init { self = [super init]; if (self) { - RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { - self->isListening = NO; - - if (self->_fullDisplay) { - [RNSentryTimeToDisplay putTimeToDisplayFor: [@"ttfd-" stringByAppendingString:self->_parentSpanId] value: newFrameTimestampInSeconds]; - return; - } - - if (self->_initialDisplay) { - [RNSentryTimeToDisplay putTimeToDisplayFor: [@"ttid-" stringByAppendingString:self->_parentSpanId] value: newFrameTimestampInSeconds]; - return; - } - }; + _spanIdUsed = NO; + RNSentryEmitNewFrameEvent emitNewFrameEvent = [self createEmitNewFrameEvent]; _framesListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] andEventEmitter:emitNewFrameEvent]; @@ -48,12 +37,48 @@ - (instancetype)init return self; } +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent +{ + return ^(NSNumber *newFrameTimestampInSeconds) { + self->isListening = NO; + + if (self->_fullDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttfd-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + + if (self->_initialDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttid-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + }; +} + - (void)didSetProps:(NSArray *)changedProps { - if ((_fullDisplay || _initialDisplay) && [_parentSpanId isKindOfClass:[NSString class]]) { - if (!isListening) { - [_framesListener startListening]; + if (![_parentSpanId isKindOfClass:[NSString class]]) { + _previousParentSpanId = nil; + return; + } + + if ([_parentSpanId isEqualToString:_previousParentSpanId] && _spanIdUsed) { + _previousInitialDisplay = _initialDisplay; + _previousFullDisplay = _fullDisplay; + return; + } + + _previousParentSpanId = _parentSpanId; + _spanIdUsed = NO; + + if (_fullDisplay || _initialDisplay) { + if (!isListening && !_spanIdUsed) { + _spanIdUsed = YES; isListening = YES; + [_framesListener startListening]; } } } diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m index d4312b6143..6326f3e2d7 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.m +++ b/packages/core/ios/RNSentryTimeToDisplay.m @@ -10,19 +10,22 @@ @implementation RNSentryTimeToDisplay { static const int ENTRIES_MAX_SIZE = 50; static NSMutableDictionary *screenIdToRenderDuration; -+ (void)initialize { ++ (void)initialize +{ if (self == [RNSentryTimeToDisplay class]) { screenIdToRenderDuration = [[NSMutableDictionary alloc] init]; } } -+ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId { ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId +{ NSNumber *value = screenIdToRenderDuration[screenId]; [screenIdToRenderDuration removeObjectForKey:screenId]; return value; } -+ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value { ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value +{ // Remove oldest entry if at max size if (screenIdToRenderDuration.count >= ENTRIES_MAX_SIZE) { NSString *firstKey = screenIdToRenderDuration.allKeys.firstObject; From f399dad273fae10aaf17114673414b45cd603eb7 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 23:56:21 +0100 Subject: [PATCH 20/33] fix android tests deps --- packages/core/RNSentryAndroidTester/app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/RNSentryAndroidTester/app/build.gradle b/packages/core/RNSentryAndroidTester/app/build.gradle index c47402b891..26334b0f85 100644 --- a/packages/core/RNSentryAndroidTester/app/build.gradle +++ b/packages/core/RNSentryAndroidTester/app/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.test:core-ktx:1.6.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.10.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' From df0619579499551bde8c803310a4edcc0647fca6 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 21 Mar 2025 23:56:46 +0100 Subject: [PATCH 21/33] add full display to sample app --- samples/react-native/src/Screens/ErrorsScreen.tsx | 2 ++ samples/react-native/src/Screens/PerformanceScreen.tsx | 2 ++ samples/react-native/src/Screens/PlaygroundScreen.tsx | 2 ++ samples/react-native/src/components/ArticleCard.tsx | 4 +--- samples/react-native/src/utils.ts | 6 ++++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index 97e04a972d..c1e3209246 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -19,6 +19,7 @@ import { UserFeedbackModal } from '../components/UserFeedbackModal'; import { FallbackRender } from '@sentry/react'; import NativeSampleModule from '../../tm/NativeSampleModule'; import NativePlatformSampleModule from '../../tm/NativePlatformSampleModule'; +import { TimeToFullDisplay } from '../utils'; const { AssetsModule, CppModule, CrashModule } = NativeModules; @@ -46,6 +47,7 @@ const ErrorsScreen = (_props: Props) => { <> +