From d94d84347867a1a5f5387a96a672bb866aab1862 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 27 Aug 2026 15:19:47 +0200 Subject: [PATCH] fix(tracing): Keep launch-screen TTID/TTFD anchored to navigation on delayed first navigation For non-standalone app start tracking, attaching app start to the first navigation transaction re-anchored its start_timestamp to process init, which made auto time_to_initial_display/time_to_full_display absorb the uninstrumented gap before a delayed first navigation (splash / auth / loading). The later 30s TTID/TTFD cap then right-censored this into a large plateau. When the gap between app start end and the first navigation start exceeds 5s, report the app_start_cold/app_start_warm measurement but leave the screen TTID/TTFD anchored to the navigation start, so they measure the actual screen render. Genuine cold-start displays (first navigation follows app start promptly) keep the existing app-start-anchored behavior unchanged. Fixes #6618 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + .../src/js/tracing/integrations/appStart.ts | 35 +++++ .../tracing/integrations/appStart.test.ts | 136 ++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d34db6a9..9ad7e9da36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Fixes + +- `time_to_initial_display`/`time_to_full_display` now measure the actual screen render for apps whose first navigation happens well after app start ([#6626](https://github.com/getsentry/sentry-react-native/pull/6626)) + ## 8.24.0 ### Features diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 2b737a8f62..5900ac25c7 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -74,6 +74,16 @@ const MAX_APP_START_DURATION_MS = 60_000; /** We filter out App starts which timestamp is 60s and more before the transaction start */ const MAX_APP_START_AGE_MS = 60_000; +/** + * When the first navigation transaction starts more than this long after the app finished starting, + * it is a normal (delayed) screen load — e.g. after a splash / auth / loading screen — not the + * cold-start's initial display. App start is decoupled from JS navigation in React Native, so we still + * report the `app_start_*` measurement, but we do NOT re-anchor the screen's TTID/TTFD to process init. + * Re-anchoring would make TTID/TTFD absorb the uninstrumented gap between app start end and the first + * navigation, inflating them well beyond the actual screen render time. + */ +const MAX_APP_START_TO_FIRST_DISPLAY_GAP_MS = 5_000; + /** App Start transaction name */ const APP_START_TX_NAME = 'App Start'; @@ -722,6 +732,31 @@ export const appStartIntegration = ({ event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; event.contexts.trace.origin = origin; + // If the first navigation transaction starts well after the app finished starting, it is a normal + // (delayed) screen load, not the cold-start's initial display. In React Native the JS navigation is + // decoupled from the native app start, so this gap is uninstrumented time (splash / auth / loading) + // that belongs to neither the app start nor the screen render. Report the app start measurement, but + // leave `event.start_timestamp` (and therefore TTID/TTFD, which are derived from it) anchored to the + // navigation start so they measure the actual screen render. Re-anchoring to process init here — and + // adding the process-init-anchored breakdown spans — would make TTID/TTFD absorb the whole gap. + // Skipped for standalone (the transaction *is* the app start) and in dev builds (long dev app starts). + const appReadyToFirstDisplayGapMs = event.start_timestamp + ? event.start_timestamp * 1000 - originalAppStartEndTimestampMs + : 0; + if (!standalone && !__DEV__ && appReadyToFirstDisplayGapMs > MAX_APP_START_TO_FIRST_DISPLAY_GAP_MS) { + debug.log( + '[AppStart] First navigation is delayed past the app start end. Reporting the app start measurement ' + + 'only and leaving the screen TTID/TTFD anchored to the navigation start.', + ); + const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; + event.measurements = event.measurements || {}; + event.measurements[measurementKey] = { + value: appStartDurationMs, + unit: 'millisecond', + }; + return true; + } + const appStartTimestampSeconds = appStartTimestampMs / 1000; const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000; event.start_timestamp = appStartTimestampSeconds; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 6b8f5e751f..81d7e70dea 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -764,6 +764,101 @@ describe('App Start Integration', () => { ); }); + it('Reports app start measurement but keeps TTID anchored to navigation when first navigation is delayed', async () => { + set__DEV__(false); + const { appStartTimeMilliseconds, appStartDurationMilliseconds, navigationStartTimestampSeconds } = + mockAppStartWithFirstNavigationGap({ cold: true, gapMilliseconds: 16000 }); + + const actualEvent = (await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }), + )) as TransactionEvent; + + // Start timestamp is NOT rewritten to process init — it stays at the navigation start so + // TTID/TTFD (derived from it by the timeToDisplay integration) measure the real screen render. + expect(actualEvent.start_timestamp).toBe(navigationStartTimestampSeconds); + expect(actualEvent.start_timestamp).not.toBe(appStartTimeMilliseconds / 1000); + + // The app start vital is still reported. + expect(actualEvent.measurements?.[APP_START_COLD_MEASUREMENT]).toEqual({ + value: appStartDurationMilliseconds, + unit: 'millisecond', + }); + + // The carrier transaction is marked as a screen load. + expect(actualEvent.contexts?.trace?.op).toBe(UI_LOAD); + expect(actualEvent.contexts?.trace?.origin).toBe(SPAN_ORIGIN_AUTO_APP_START); + + // No process-init-anchored breakdown span is added (it would be out of the transaction bounds). + expect(actualEvent.spans?.find(({ description }) => description === 'Cold Start')).toBeUndefined(); + // The original span is left untouched, and no span starts before the (navigation) transaction start. + expect(actualEvent.spans).toEqual([ + { + start_timestamp: 100, + timestamp: 200, + op: 'test', + description: 'Test', + span_id: '123', + trace_id: '456', + data: {}, + }, + ]); + }); + + it('Keeps app-start-anchored behavior when the first navigation follows app start promptly', async () => { + set__DEV__(false); + const { appStartTimeMilliseconds, appStartEndTimestampMilliseconds, navigationStartTimestampSeconds } = + mockAppStartWithFirstNavigationGap({ cold: true, gapMilliseconds: 4000 }); + + const actualEvent = (await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }), + )) as TransactionEvent; + + // Gap is under the threshold, so this is treated as the cold start's initial display: the + // transaction start is re-anchored to process init and the Cold Start breakdown span is added. + expect(actualEvent.start_timestamp).toBe(appStartTimeMilliseconds / 1000); + expect(actualEvent).toEqual( + expectEventWithAttachedColdAppStart({ + timeOriginMilliseconds: appStartEndTimestampMilliseconds, + appStartTimeMilliseconds, + }), + ); + }); + + it('Reports warm app start measurement while keeping TTID anchored on a delayed first navigation', async () => { + set__DEV__(false); + const { appStartDurationMilliseconds, navigationStartTimestampSeconds } = mockAppStartWithFirstNavigationGap({ + cold: false, + gapMilliseconds: 16000, + }); + + const actualEvent = (await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }), + )) as TransactionEvent; + + expect(actualEvent.start_timestamp).toBe(navigationStartTimestampSeconds); + expect(actualEvent.measurements?.[APP_START_WARM_MEASUREMENT]).toEqual({ + value: appStartDurationMilliseconds, + unit: 'millisecond', + }); + expect(actualEvent.spans?.find(({ description }) => description === 'Warm Start')).toBeUndefined(); + }); + + it('Does not apply the delayed-first-navigation branch in development builds', async () => { + set__DEV__(true); + const { appStartTimeMilliseconds, navigationStartTimestampSeconds } = mockAppStartWithFirstNavigationGap({ + cold: true, + gapMilliseconds: 16000, + }); + + const actualEvent = (await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }), + )) as TransactionEvent; + + // Dev builds keep the existing behavior (start re-anchored, breakdown span added). + expect(actualEvent.start_timestamp).toBe(appStartTimeMilliseconds / 1000); + expect(actualEvent.spans?.find(({ description }) => description === 'Cold Start')).toBeDefined(); + }); + it('Does not create app start transaction if has_fetched == true', async () => { mockAppStart({ has_fetched: true }); @@ -2592,6 +2687,47 @@ function mockTooOldAppStart() { return [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds]; } +/** + * Mocks an app start followed by a first navigation that begins `gapMilliseconds` after the app + * finished starting. Used to exercise the delayed-first-navigation branch, where the app start + * measurement is still reported but the screen TTID/TTFD stays anchored to the navigation start. + * + * The app start itself is short (2s) and recent, so the existing age (60s) and duration (60s) guards + * do not fire — the only discriminator is the gap between app start end and the navigation start. + */ +function mockAppStartWithFirstNavigationGap({ + cold = true, + gapMilliseconds, +}: { + cold?: boolean; + gapMilliseconds: number; +}) { + const appStartTimeMilliseconds = Date.now(); + const appStartEndTimestampMilliseconds = appStartTimeMilliseconds + 2000; + const appStartDurationMilliseconds = appStartEndTimestampMilliseconds - appStartTimeMilliseconds; + const navigationStartTimestampSeconds = (appStartEndTimestampMilliseconds + gapMilliseconds) / 1000; + const mockAppStartResponse: NativeAppStartResponse = { + type: cold ? 'cold' : 'warm', + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: false, + spans: [], + }; + + _setAppStartEndData({ + timestampMs: appStartEndTimestampMilliseconds, + endFrames: null, + }); + mockFunction(getTimeOriginMilliseconds).mockReturnValue(appStartEndTimestampMilliseconds); + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); + + return { + appStartTimeMilliseconds, + appStartEndTimestampMilliseconds, + appStartDurationMilliseconds, + navigationStartTimestampSeconds, + }; +} + /** * Mocks RN Bundle Start Module * `var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()`