From 290f3c3ec2ac4b99be9c1a11dfce5b1be10e8ce1 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Fri, 14 Mar 2025 16:00:52 +0100 Subject: [PATCH 1/4] 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 2/4] 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 a26b5766d30dc737a65a7f26b7ab717e64abc4cd Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:55:55 +0100 Subject: [PATCH 3/4] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index add4e5eb9b..faeb39cf52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - 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 ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665)) +- 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 From e0ce666b9535c57ac6e5e69dd454ecee25fbb902 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:29:42 +0100 Subject: [PATCH 4/4] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faeb39cf52..aa75c31296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,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 ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665)) +- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680)) ### Dependencies