Skip to content

Commit b12e8e8

Browse files
committed
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). When appLoaded() is called it: - Records the current timestamp as the manual app start end - Fetches native frames for frame data attachment - Triggers standalone app start capture when in standalone mode Priority / race condition handling: - appLoaded() before ReactNativeProfiler.componentDidMount: the auto capture in _captureAppStart({ isManual: false }) is skipped entirely - appLoaded() after componentDidMount: the existing appStartEndData timestamp is overwritten with the manual (later) value; since the app start spans are attached to the first navigation transaction (which hasn't been flushed yet), the correct timestamp is used Existing behaviour is unchanged when appLoaded() is not called. The three-tier fallback (wrap -> bundle start -> warn) still applies. Adds _appLoaded() internal function, _clearAppStartEndData() testing helper, and five new unit tests covering: manual timestamp, duplicate call guard, post-auto-capture override, pre-auto-capture guard, and no-op before Sentry.init().
1 parent 849d593 commit b12e8e8

File tree

5 files changed

+224
-1
lines changed

5 files changed

+224
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
99
## Unreleased
1010

11+
### Features
12+
13+
- Add `Sentry.appLoaded()` API to explicitly signal app start end ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX))
14+
1115
### Fixes
1216

1317
- Fix iOS crash (EXC_BAD_ACCESS) in time-to-initial-display when navigating between screens ([#5887](https://github.com/getsentry/sentry-react-native/pull/5887))

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: 24 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,29 @@ 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+
* @example
235+
* ```ts
236+
* await loadRemoteConfig();
237+
* await restoreSession();
238+
* SplashScreen.hide();
239+
* Sentry.appLoaded();
240+
* ```
241+
*/
242+
export function appLoaded(): void {
243+
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
244+
_appLoaded();
245+
}
246+
223247
/**
224248
* Flushes all pending events in the queue to disk.
225249
* Use this before applying any realtime updates such as code-push or expo updates.

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface AppStartEndData {
5959

6060
let appStartEndData: AppStartEndData | undefined = undefined;
6161
let isRecordedAppStartEndTimestampMsManual = false;
62+
let isAppLoadedManuallyInvoked = false;
6263

6364
let rootComponentCreationTimestampMs: number | undefined = undefined;
6465
let isRootComponentCreationTimestampMsManual = false;
@@ -71,12 +72,64 @@ export function captureAppStart(): Promise<void> {
7172
return _captureAppStart({ isManual: true });
7273
}
7374

75+
/**
76+
* Signals that the app has finished loading and is ready for user interaction.
77+
* Called internally by `appLoaded()` from the public SDK API.
78+
*
79+
* @private
80+
*/
81+
export async function _appLoaded(): Promise<void> {
82+
if (isAppLoadedManuallyInvoked) {
83+
debug.warn('[AppStart] appLoaded() was already called. Subsequent calls are ignored.');
84+
return;
85+
}
86+
isAppLoadedManuallyInvoked = true;
87+
88+
const client = getClient();
89+
if (!client) {
90+
debug.warn('[AppStart] appLoaded() was called before Sentry.init(). App start end will not be recorded.');
91+
return;
92+
}
93+
94+
const timestampMs = timestampInSeconds() * 1000;
95+
96+
// If auto-capture already ran (ReactNativeProfiler.componentDidMount), overwrite the timestamp.
97+
// The transaction hasn't been sent yet in non-standalone mode so this is safe.
98+
if (appStartEndData) {
99+
debug.log('[AppStart] appLoaded() overwriting auto-detected app start end timestamp.');
100+
appStartEndData.timestampMs = timestampMs;
101+
appStartEndData.endFrames = null;
102+
} else {
103+
_setAppStartEndData({ timestampMs, endFrames: null });
104+
}
105+
isRecordedAppStartEndTimestampMsManual = true;
106+
107+
if (NATIVE.enableNative) {
108+
try {
109+
const endFrames = await NATIVE.fetchNativeFrames();
110+
debug.log('[AppStart] Captured end frames for app start.', endFrames);
111+
_updateAppStartEndFrames(endFrames);
112+
} catch (error) {
113+
debug.log('[AppStart] Failed to capture end frames for app start.', error);
114+
}
115+
}
116+
117+
await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.captureStandaloneAppStart();
118+
}
119+
74120
/**
75121
* For internal use only.
76122
*
77123
* @private
78124
*/
79125
export async function _captureAppStart({ isManual }: { isManual: boolean }): Promise<void> {
126+
// If appLoaded() was already called manually, skip the auto-capture to avoid
127+
// overwriting the manual end timestamp (race B: appLoaded before componentDidMount).
128+
if (!isManual && isAppLoadedManuallyInvoked) {
129+
debug.log('[AppStart] Skipping auto app start capture because appLoaded() was already called.');
130+
return;
131+
}
132+
80133
const client = getClient();
81134
if (!client) {
82135
debug.warn('[AppStart] Could not capture App Start, missing client.');
@@ -160,6 +213,17 @@ export function _clearRootComponentCreationTimestampMs(): void {
160213
rootComponentCreationTimestampMs = undefined;
161214
}
162215

216+
/**
217+
* For testing purposes only.
218+
*
219+
* @private
220+
*/
221+
export function _clearAppStartEndData(): void {
222+
appStartEndData = undefined;
223+
isRecordedAppStartEndTimestampMsManual = false;
224+
isAppLoadedManuallyInvoked = false;
225+
}
226+
163227
/**
164228
* Attaches frame data to a span's data object.
165229
*/

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ErrorEvent, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core';
22

33
import {
4+
debug,
45
getCurrentScope,
56
getGlobalScope,
67
getIsolationScope,
@@ -24,7 +25,9 @@ import {
2425
UI_LOAD,
2526
} from '../../../src/js/tracing';
2627
import {
28+
_appLoaded,
2729
_captureAppStart,
30+
_clearAppStartEndData,
2831
_clearRootComponentCreationTimestampMs,
2932
_setAppStartEndData,
3033
_setRootComponentCreationTimestampMs,
@@ -1024,6 +1027,134 @@ describe('App Start Integration', () => {
10241027
});
10251028
});
10261029

1030+
describe('appLoaded() API', () => {
1031+
let client: TestClient;
1032+
1033+
beforeEach(() => {
1034+
jest.clearAllMocks();
1035+
_clearAppStartEndData();
1036+
_clearRootComponentCreationTimestampMs();
1037+
mockReactNativeBundleExecutionStartTimestamp();
1038+
client = new TestClient({
1039+
...getDefaultTestClientOptions(),
1040+
enableAppStartTracking: true,
1041+
tracesSampleRate: 1.0,
1042+
});
1043+
setCurrentClient(client);
1044+
client.init();
1045+
});
1046+
1047+
afterEach(() => {
1048+
clearReactNativeBundleExecutionStartTimestamp();
1049+
_clearAppStartEndData();
1050+
_clearRootComponentCreationTimestampMs();
1051+
});
1052+
1053+
function makeIntegration(): AppStartIntegrationTest {
1054+
const integration = appStartIntegration({ standalone: false }) as AppStartIntegrationTest;
1055+
integration.setup(client);
1056+
integration.afterAllSetup(client);
1057+
return integration;
1058+
}
1059+
1060+
it('sets the app start end timestamp and marks it as manual', async () => {
1061+
const appLoadedTimeSeconds = Date.now() / 1000;
1062+
mockFunction(timestampInSeconds).mockReturnValue(appLoadedTimeSeconds);
1063+
1064+
await _appLoaded();
1065+
1066+
const appStartTimeMilliseconds = appLoadedTimeSeconds * 1000 - 3000;
1067+
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({
1068+
type: 'cold' as const,
1069+
app_start_timestamp_ms: appStartTimeMilliseconds,
1070+
has_fetched: false,
1071+
spans: [],
1072+
});
1073+
1074+
const integration = makeIntegration();
1075+
integration.setFirstStartedActiveRootSpanId('test-span-id');
1076+
1077+
const event: TransactionEvent = {
1078+
type: 'transaction',
1079+
start_timestamp: Date.now() / 1000,
1080+
timestamp: Date.now() / 1000 + 1,
1081+
contexts: { trace: { span_id: 'test-span-id', trace_id: 'trace123' } },
1082+
};
1083+
1084+
const processed = (await integration.processEvent(event, {}, client)) as TransactionEvent;
1085+
const appStartSpan = processed.spans?.find(s => s.op === APP_START_COLD_OP);
1086+
1087+
expect(appStartSpan).toBeDefined();
1088+
expect(appStartSpan?.timestamp).toBeCloseTo(appLoadedTimeSeconds, 1);
1089+
expect(appStartSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START);
1090+
});
1091+
1092+
it('ignores subsequent calls after first invocation', async () => {
1093+
const warnSpy = jest.spyOn(debug, 'warn');
1094+
1095+
await _appLoaded();
1096+
await _appLoaded();
1097+
1098+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('already called'));
1099+
});
1100+
1101+
it('overrides auto-detected timestamp when called after _captureAppStart', async () => {
1102+
const now = Date.now();
1103+
const autoTimeSeconds = now / 1000;
1104+
const manualTimeSeconds = now / 1000 + 0.5;
1105+
const appStartTimeMilliseconds = now - 3000;
1106+
1107+
mockFunction(timestampInSeconds).mockReturnValueOnce(autoTimeSeconds);
1108+
await _captureAppStart({ isManual: false });
1109+
1110+
mockFunction(timestampInSeconds).mockReturnValueOnce(manualTimeSeconds);
1111+
await _appLoaded();
1112+
1113+
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({
1114+
type: 'cold' as const,
1115+
app_start_timestamp_ms: appStartTimeMilliseconds,
1116+
has_fetched: false,
1117+
spans: [],
1118+
});
1119+
1120+
const integration = makeIntegration();
1121+
integration.setFirstStartedActiveRootSpanId('span-override');
1122+
1123+
const event: TransactionEvent = {
1124+
type: 'transaction',
1125+
start_timestamp: now / 1000,
1126+
timestamp: now / 1000 + 2,
1127+
contexts: { trace: { span_id: 'span-override', trace_id: 'trace' } },
1128+
};
1129+
1130+
const processed = (await integration.processEvent(event, {}, client)) as TransactionEvent;
1131+
const appStartSpan = processed.spans?.find(s => s.op === APP_START_COLD_OP);
1132+
expect(appStartSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1);
1133+
});
1134+
1135+
it('prevents auto-capture from overriding when called before _captureAppStart', async () => {
1136+
await _appLoaded();
1137+
1138+
const logSpy = jest.spyOn(debug, 'log');
1139+
await _captureAppStart({ isManual: false });
1140+
1141+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping auto app start capture'));
1142+
});
1143+
1144+
it('warns and is a no-op when called before Sentry.init', async () => {
1145+
const warnSpy = jest.spyOn(debug, 'warn');
1146+
getCurrentScope().setClient(undefined);
1147+
getGlobalScope().setClient(undefined);
1148+
getIsolationScope().setClient(undefined);
1149+
1150+
await _appLoaded();
1151+
1152+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before Sentry.init'));
1153+
1154+
setCurrentClient(client);
1155+
});
1156+
});
1157+
10271158
describe('Frame Data Integration', () => {
10281159
it('attaches frame data to standalone cold app start span', async () => {
10291160
const mockEndFrames = {

0 commit comments

Comments
 (0)