Skip to content

Commit d0eaaeb

Browse files
antonisclaude
andcommitted
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 <noreply@anthropic.com>
1 parent dbc0a89 commit d0eaaeb

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Fixes
12+
13+
- Auto `time_to_initial_display`/`time_to_full_display` on the launch screen no longer includes the idle time before a delayed first navigation. TTID/TTFD now measure the actual screen render for apps whose first navigation happens well after app start; the `app_start_cold`/`app_start_warm` measurements are unchanged ([#6618](https://github.com/getsentry/sentry-react-native/issues/6618))
14+
915
## 8.24.0
1016

1117
### Features

‎packages/core/src/js/tracing/integrations/appStart.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ const MAX_APP_START_DURATION_MS = 60_000;
7474
/** We filter out App starts which timestamp is 60s and more before the transaction start */
7575
const MAX_APP_START_AGE_MS = 60_000;
7676

77+
/**
78+
* When the first navigation transaction starts more than this long after the app finished starting,
79+
* it is a normal (delayed) screen load — e.g. after a splash / auth / loading screen — not the
80+
* cold-start's initial display. App start is decoupled from JS navigation in React Native, so we still
81+
* report the `app_start_*` measurement, but we do NOT re-anchor the screen's TTID/TTFD to process init.
82+
* Re-anchoring would make TTID/TTFD absorb the uninstrumented gap between app start end and the first
83+
* navigation, inflating them well beyond the actual screen render time.
84+
*/
85+
const MAX_APP_START_TO_FIRST_DISPLAY_GAP_MS = 5_000;
86+
7787
/** App Start transaction name */
7888
const APP_START_TX_NAME = 'App Start';
7989

@@ -722,6 +732,31 @@ export const appStartIntegration = ({
722732
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin;
723733
event.contexts.trace.origin = origin;
724734

735+
// If the first navigation transaction starts well after the app finished starting, it is a normal
736+
// (delayed) screen load, not the cold-start's initial display. In React Native the JS navigation is
737+
// decoupled from the native app start, so this gap is uninstrumented time (splash / auth / loading)
738+
// that belongs to neither the app start nor the screen render. Report the app start measurement, but
739+
// leave `event.start_timestamp` (and therefore TTID/TTFD, which are derived from it) anchored to the
740+
// navigation start so they measure the actual screen render. Re-anchoring to process init here — and
741+
// adding the process-init-anchored breakdown spans — would make TTID/TTFD absorb the whole gap.
742+
// Skipped for standalone (the transaction *is* the app start) and in dev builds (long dev app starts).
743+
const appReadyToFirstDisplayGapMs = event.start_timestamp
744+
? event.start_timestamp * 1000 - originalAppStartEndTimestampMs
745+
: 0;
746+
if (!standalone && !__DEV__ && appReadyToFirstDisplayGapMs > MAX_APP_START_TO_FIRST_DISPLAY_GAP_MS) {
747+
debug.log(
748+
'[AppStart] First navigation is delayed past the app start end. Reporting the app start measurement ' +
749+
'only and leaving the screen TTID/TTFD anchored to the navigation start.',
750+
);
751+
const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT;
752+
event.measurements = event.measurements || {};
753+
event.measurements[measurementKey] = {
754+
value: appStartDurationMs,
755+
unit: 'millisecond',
756+
};
757+
return true;
758+
}
759+
725760
const appStartTimestampSeconds = appStartTimestampMs / 1000;
726761
const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000;
727762
event.start_timestamp = appStartTimestampSeconds;

‎packages/core/test/tracing/integrations/appStart.test.ts‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,101 @@ describe('App Start Integration', () => {
764764
);
765765
});
766766

767+
it('Reports app start measurement but keeps TTID anchored to navigation when first navigation is delayed', async () => {
768+
set__DEV__(false);
769+
const { appStartTimeMilliseconds, appStartDurationMilliseconds, navigationStartTimestampSeconds } =
770+
mockAppStartWithFirstNavigationGap({ cold: true, gapMilliseconds: 16000 });
771+
772+
const actualEvent = (await processEvent(
773+
getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }),
774+
)) as TransactionEvent;
775+
776+
// Start timestamp is NOT rewritten to process init — it stays at the navigation start so
777+
// TTID/TTFD (derived from it by the timeToDisplay integration) measure the real screen render.
778+
expect(actualEvent.start_timestamp).toBe(navigationStartTimestampSeconds);
779+
expect(actualEvent.start_timestamp).not.toBe(appStartTimeMilliseconds / 1000);
780+
781+
// The app start vital is still reported.
782+
expect(actualEvent.measurements?.[APP_START_COLD_MEASUREMENT]).toEqual({
783+
value: appStartDurationMilliseconds,
784+
unit: 'millisecond',
785+
});
786+
787+
// The carrier transaction is marked as a screen load.
788+
expect(actualEvent.contexts?.trace?.op).toBe(UI_LOAD);
789+
expect(actualEvent.contexts?.trace?.origin).toBe(SPAN_ORIGIN_AUTO_APP_START);
790+
791+
// No process-init-anchored breakdown span is added (it would be out of the transaction bounds).
792+
expect(actualEvent.spans?.find(({ description }) => description === 'Cold Start')).toBeUndefined();
793+
// The original span is left untouched, and no span starts before the (navigation) transaction start.
794+
expect(actualEvent.spans).toEqual([
795+
{
796+
start_timestamp: 100,
797+
timestamp: 200,
798+
op: 'test',
799+
description: 'Test',
800+
span_id: '123',
801+
trace_id: '456',
802+
data: {},
803+
},
804+
]);
805+
});
806+
807+
it('Keeps app-start-anchored behavior when the first navigation follows app start promptly', async () => {
808+
set__DEV__(false);
809+
const { appStartTimeMilliseconds, appStartEndTimestampMilliseconds, navigationStartTimestampSeconds } =
810+
mockAppStartWithFirstNavigationGap({ cold: true, gapMilliseconds: 4000 });
811+
812+
const actualEvent = (await processEvent(
813+
getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }),
814+
)) as TransactionEvent;
815+
816+
// Gap is under the threshold, so this is treated as the cold start's initial display: the
817+
// transaction start is re-anchored to process init and the Cold Start breakdown span is added.
818+
expect(actualEvent.start_timestamp).toBe(appStartTimeMilliseconds / 1000);
819+
expect(actualEvent).toEqual(
820+
expectEventWithAttachedColdAppStart({
821+
timeOriginMilliseconds: appStartEndTimestampMilliseconds,
822+
appStartTimeMilliseconds,
823+
}),
824+
);
825+
});
826+
827+
it('Reports warm app start measurement while keeping TTID anchored on a delayed first navigation', async () => {
828+
set__DEV__(false);
829+
const { appStartDurationMilliseconds, navigationStartTimestampSeconds } = mockAppStartWithFirstNavigationGap({
830+
cold: false,
831+
gapMilliseconds: 16000,
832+
});
833+
834+
const actualEvent = (await processEvent(
835+
getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }),
836+
)) as TransactionEvent;
837+
838+
expect(actualEvent.start_timestamp).toBe(navigationStartTimestampSeconds);
839+
expect(actualEvent.measurements?.[APP_START_WARM_MEASUREMENT]).toEqual({
840+
value: appStartDurationMilliseconds,
841+
unit: 'millisecond',
842+
});
843+
expect(actualEvent.spans?.find(({ description }) => description === 'Warm Start')).toBeUndefined();
844+
});
845+
846+
it('Does not apply the delayed-first-navigation branch in development builds', async () => {
847+
set__DEV__(true);
848+
const { appStartTimeMilliseconds, navigationStartTimestampSeconds } = mockAppStartWithFirstNavigationGap({
849+
cold: true,
850+
gapMilliseconds: 16000,
851+
});
852+
853+
const actualEvent = (await processEvent(
854+
getMinimalTransactionEvent({ startTimestampSeconds: navigationStartTimestampSeconds }),
855+
)) as TransactionEvent;
856+
857+
// Dev builds keep the existing behavior (start re-anchored, breakdown span added).
858+
expect(actualEvent.start_timestamp).toBe(appStartTimeMilliseconds / 1000);
859+
expect(actualEvent.spans?.find(({ description }) => description === 'Cold Start')).toBeDefined();
860+
});
861+
767862
it('Does not create app start transaction if has_fetched == true', async () => {
768863
mockAppStart({ has_fetched: true });
769864

@@ -2592,6 +2687,47 @@ function mockTooOldAppStart() {
25922687
return [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds];
25932688
}
25942689

2690+
/**
2691+
* Mocks an app start followed by a first navigation that begins `gapMilliseconds` after the app
2692+
* finished starting. Used to exercise the delayed-first-navigation branch, where the app start
2693+
* measurement is still reported but the screen TTID/TTFD stays anchored to the navigation start.
2694+
*
2695+
* The app start itself is short (2s) and recent, so the existing age (60s) and duration (60s) guards
2696+
* do not fire — the only discriminator is the gap between app start end and the navigation start.
2697+
*/
2698+
function mockAppStartWithFirstNavigationGap({
2699+
cold = true,
2700+
gapMilliseconds,
2701+
}: {
2702+
cold?: boolean;
2703+
gapMilliseconds: number;
2704+
}) {
2705+
const appStartTimeMilliseconds = Date.now();
2706+
const appStartEndTimestampMilliseconds = appStartTimeMilliseconds + 2000;
2707+
const appStartDurationMilliseconds = appStartEndTimestampMilliseconds - appStartTimeMilliseconds;
2708+
const navigationStartTimestampSeconds = (appStartEndTimestampMilliseconds + gapMilliseconds) / 1000;
2709+
const mockAppStartResponse: NativeAppStartResponse = {
2710+
type: cold ? 'cold' : 'warm',
2711+
app_start_timestamp_ms: appStartTimeMilliseconds,
2712+
has_fetched: false,
2713+
spans: [],
2714+
};
2715+
2716+
_setAppStartEndData({
2717+
timestampMs: appStartEndTimestampMilliseconds,
2718+
endFrames: null,
2719+
});
2720+
mockFunction(getTimeOriginMilliseconds).mockReturnValue(appStartEndTimestampMilliseconds);
2721+
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
2722+
2723+
return {
2724+
appStartTimeMilliseconds,
2725+
appStartEndTimestampMilliseconds,
2726+
appStartDurationMilliseconds,
2727+
navigationStartTimestampSeconds,
2728+
};
2729+
}
2730+
25952731
/**
25962732
* Mocks RN Bundle Start Module
25972733
* `var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()`

0 commit comments

Comments
 (0)