Skip to content

Commit a50b33d

Browse files
authored
feat(core): Add Sentry.appLoaded() API to signal app start end (#5940)
* feat(core): Add Sentry.appLoaded() API to signal app start end Adds a public `Sentry.appLoaded()` function that users can call to explicitly signal when their app is fully ready for user interaction. This provides a more accurate app start end timestamp for apps that do significant async work after the root component mounts (e.g. remote config fetching, session restore, splash screen dismissal).
1 parent 4953e94 commit a50b33d

File tree

5 files changed

+513
-5
lines changed

5 files changed

+513
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Features
1212

13+
- Add `Sentry.appLoaded()` API to explicitly signal app start end ([#5940](https://github.com/getsentry/sentry-react-native/pull/5940))
1314
- Add `frames.delay` span data from native SDKs to app start, TTID/TTFD, and JS API spans ([#5907](https://github.com/getsentry/sentry-react-native/pull/5907))
1415
- Rename `FeedbackWidget` to `FeedbackForm` and `showFeedbackWidget` to `showFeedbackForm` ([#5931](https://github.com/getsentry/sentry-react-native/pull/5931))
1516
- The old names are deprecated but still work

packages/core/src/js/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export { SDK_NAME, SDK_VERSION } from './version';
7373
export type { ReactNativeOptions, NativeLogEntry } from './options';
7474
export { ReactNativeClient } from './client';
7575

76-
export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun } from './sdk';
76+
export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun, appLoaded } from './sdk';
7777
export { TouchEventBoundary, withTouchEventBoundary } from './touchevents';
7878

7979
export {

packages/core/src/js/sdk.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { shouldEnableNativeNagger } from './options';
2525
import { enableSyncToNative } from './scopeSync';
2626
import { TouchEventBoundary } from './touchevents';
2727
import { ReactNativeProfiler } from './tracing';
28+
import { _appLoaded } from './tracing/integrations/appStart';
2829
import { useEncodePolyfill } from './transports/encodePolyfill';
2930
import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native';
3031
import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer, isWeb } from './utils/environment';
@@ -220,6 +221,31 @@ export function nativeCrash(): void {
220221
NATIVE.nativeCrash();
221222
}
222223

224+
/**
225+
* Signals that the application has finished loading and is ready for user interaction.
226+
*
227+
* Call this when your app is truly ready — after async initialization, data loading,
228+
* splash screen dismissal, auth session restore, etc. This marks the end of the app start span,
229+
* giving you a more accurate measurement of perceived startup time.
230+
*
231+
* If not called, the SDK falls back to the root component mount time (via `Sentry.wrap()`)
232+
* or JS bundle execution start.
233+
*
234+
* @experimental This API is subject to change in future versions.
235+
*
236+
* @example
237+
* ```ts
238+
* await loadRemoteConfig();
239+
* await restoreSession();
240+
* SplashScreen.hide();
241+
* Sentry.appLoaded();
242+
* ```
243+
*/
244+
export function appLoaded(): void {
245+
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
246+
_appLoaded();
247+
}
248+
223249
/**
224250
* Flushes all pending events in the queue to disk.
225251
* Use this before applying any realtime updates such as code-push or expo updates.

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

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const INTEGRATION_NAME = 'AppStart';
3737

3838
export type AppStartIntegration = Integration & {
3939
captureStandaloneAppStart: () => Promise<void>;
40+
resetAppStartDataFlushed: () => void;
41+
cancelDeferredStandaloneCapture: () => void;
42+
scheduleDeferredStandaloneCapture: () => void;
4043
};
4144

4245
/**
@@ -59,24 +62,81 @@ interface AppStartEndData {
5962

6063
let appStartEndData: AppStartEndData | undefined = undefined;
6164
let isRecordedAppStartEndTimestampMsManual = false;
65+
let isAppLoadedManuallyInvoked = false;
6266

6367
let rootComponentCreationTimestampMs: number | undefined = undefined;
6468
let isRootComponentCreationTimestampMsManual = false;
6569

6670
/**
6771
* Records the application start end.
6872
* Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`.
73+
*
74+
* @deprecated Use {@link appLoaded} from the public SDK API instead (`Sentry.appLoaded()`).
6975
*/
7076
export function captureAppStart(): Promise<void> {
7177
return _captureAppStart({ isManual: true });
7278
}
7379

80+
/**
81+
* Signals that the app has finished loading and is ready for user interaction.
82+
* Called internally by `appLoaded()` from the public SDK API.
83+
*
84+
* @private
85+
*/
86+
export async function _appLoaded(): Promise<void> {
87+
if (isAppLoadedManuallyInvoked) {
88+
debug.warn('[AppStart] appLoaded() was already called. Subsequent calls are ignored.');
89+
return;
90+
}
91+
92+
const client = getClient();
93+
if (!client) {
94+
debug.warn('[AppStart] appLoaded() was called before Sentry.init(). App start end will not be recorded.');
95+
return;
96+
}
97+
98+
isAppLoadedManuallyInvoked = true;
99+
100+
const timestampMs = timestampInSeconds() * 1000;
101+
102+
// If auto-capture already ran (ReactNativeProfiler.componentDidMount), overwrite the timestamp.
103+
// The transaction hasn't been sent yet in non-standalone mode so this is safe.
104+
if (appStartEndData) {
105+
debug.log('[AppStart] appLoaded() overwriting auto-detected app start end timestamp.');
106+
appStartEndData.timestampMs = timestampMs;
107+
appStartEndData.endFrames = null;
108+
} else {
109+
_setAppStartEndData({ timestampMs, endFrames: null });
110+
}
111+
isRecordedAppStartEndTimestampMsManual = true;
112+
113+
await fetchAndUpdateEndFrames();
114+
115+
const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
116+
if (integration) {
117+
// Cancel any deferred standalone send from auto-capture — we'll send our own
118+
// with the correct manual timestamp instead of sending two transactions.
119+
integration.cancelDeferredStandaloneCapture();
120+
// In standalone mode, auto-capture may have already flushed the transaction.
121+
// Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp.
122+
integration.resetAppStartDataFlushed();
123+
await integration.captureStandaloneAppStart();
124+
}
125+
}
126+
74127
/**
75128
* For internal use only.
76129
*
77130
* @private
78131
*/
79132
export async function _captureAppStart({ isManual }: { isManual: boolean }): Promise<void> {
133+
// If appLoaded() was already called manually, skip the auto-capture to avoid
134+
// overwriting the manual end timestamp (race B: appLoaded before componentDidMount).
135+
if (!isManual && isAppLoadedManuallyInvoked) {
136+
debug.log('[AppStart] Skipping auto app start capture because appLoaded() was already called.');
137+
return;
138+
}
139+
80140
const client = getClient();
81141
if (!client) {
82142
debug.warn('[AppStart] Could not capture App Start, missing client.');
@@ -94,6 +154,26 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
94154
endFrames: null,
95155
});
96156

157+
await fetchAndUpdateEndFrames();
158+
159+
const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
160+
if (integration) {
161+
if (!isManual) {
162+
// For auto-capture, defer the standalone send to give appLoaded() a chance
163+
// to override the end timestamp before the transaction is sent.
164+
// If appLoaded() is called, it cancels this deferred send and sends its own.
165+
// In non-standalone mode, scheduleDeferredStandaloneCapture is a no-op.
166+
integration.scheduleDeferredStandaloneCapture();
167+
} else {
168+
await integration.captureStandaloneAppStart();
169+
}
170+
}
171+
}
172+
173+
/**
174+
* Fetches native frames data and attaches it to the current app start end data.
175+
*/
176+
async function fetchAndUpdateEndFrames(): Promise<void> {
97177
if (NATIVE.enableNative) {
98178
try {
99179
const endFrames = await NATIVE.fetchNativeFrames();
@@ -103,8 +183,6 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
103183
debug.log('[AppStart] Failed to capture end frames for app start.', error);
104184
}
105185
}
106-
107-
await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.captureStandaloneAppStart();
108186
}
109187

110188
/**
@@ -160,6 +238,17 @@ export function _clearRootComponentCreationTimestampMs(): void {
160238
rootComponentCreationTimestampMs = undefined;
161239
}
162240

241+
/**
242+
* For testing purposes only.
243+
*
244+
* @private
245+
*/
246+
export function _clearAppStartEndData(): void {
247+
appStartEndData = undefined;
248+
isRecordedAppStartEndTimestampMsManual = false;
249+
isAppLoadedManuallyInvoked = false;
250+
}
251+
163252
/**
164253
* Attaches frame data to a span's data object.
165254
*/
@@ -203,6 +292,8 @@ export const appStartIntegration = ({
203292
let afterAllSetupCalled = false;
204293
let firstStartedActiveRootSpanId: string | undefined = undefined;
205294
let firstStartedActiveRootSpan: Span | undefined = undefined;
295+
let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined;
296+
let deferredStandaloneTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
206297

207298
const setup = (client: Client): void => {
208299
_client = client;
@@ -230,6 +321,12 @@ export const appStartIntegration = ({
230321
appStartDataFlushed = false;
231322
firstStartedActiveRootSpanId = undefined;
232323
firstStartedActiveRootSpan = undefined;
324+
isAppLoadedManuallyInvoked = false;
325+
cachedNativeAppStart = undefined;
326+
if (deferredStandaloneTimeout !== undefined) {
327+
clearTimeout(deferredStandaloneTimeout);
328+
deferredStandaloneTimeout = undefined;
329+
}
233330
} else {
234331
debug.log(
235332
'[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.',
@@ -395,13 +492,23 @@ export const appStartIntegration = ({
395492

396493
// All failure paths below set appStartDataFlushed = true to prevent
397494
// wasteful retries — these conditions won't change within the same app start.
398-
const appStart = await NATIVE.fetchNativeAppStart();
495+
//
496+
// Use cached response if available (e.g. when _appLoaded() re-triggers
497+
// standalone capture after auto-capture already fetched from the native layer).
498+
// The native layer sets has_fetched = true after the first fetch, so a second
499+
// NATIVE.fetchNativeAppStart() call would incorrectly bail out.
500+
const isCached = cachedNativeAppStart !== undefined;
501+
const appStart = isCached ? cachedNativeAppStart : await NATIVE.fetchNativeAppStart();
502+
cachedNativeAppStart = appStart;
399503
if (!appStart) {
400504
debug.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.');
401505
appStartDataFlushed = true;
402506
return;
403507
}
404-
if (appStart.has_fetched) {
508+
// Skip the has_fetched check when using a cached response — the native layer
509+
// sets has_fetched = true after the first fetch, but we intentionally re-use
510+
// the data when _appLoaded() overrides the app start end timestamp.
511+
if (!isCached && appStart.has_fetched) {
405512
debug.warn('[AppStart] Measured app start metrics were already reported from the native layer.');
406513
appStartDataFlushed = true;
407514
return;
@@ -540,12 +647,38 @@ export const appStartIntegration = ({
540647
);
541648
}
542649

650+
const resetAppStartDataFlushed = (): void => {
651+
appStartDataFlushed = false;
652+
};
653+
654+
const cancelDeferredStandaloneCapture = (): void => {
655+
if (deferredStandaloneTimeout !== undefined) {
656+
clearTimeout(deferredStandaloneTimeout);
657+
deferredStandaloneTimeout = undefined;
658+
debug.log('[AppStart] Cancelled deferred standalone app start capture.');
659+
}
660+
};
661+
662+
const scheduleDeferredStandaloneCapture = (): void => {
663+
if (!standalone) {
664+
return;
665+
}
666+
deferredStandaloneTimeout = setTimeout(() => {
667+
deferredStandaloneTimeout = undefined;
668+
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
669+
captureStandaloneAppStart();
670+
}, 0);
671+
};
672+
543673
return {
544674
name: INTEGRATION_NAME,
545675
setup,
546676
afterAllSetup,
547677
processEvent,
548678
captureStandaloneAppStart,
679+
resetAppStartDataFlushed,
680+
cancelDeferredStandaloneCapture,
681+
scheduleDeferredStandaloneCapture,
549682
setFirstStartedActiveRootSpanId,
550683
} as AppStartIntegration;
551684
};

0 commit comments

Comments
 (0)