Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Features

- Add `Sentry.appLoaded()` API to explicitly signal app start end ([#5940](https://github.com/getsentry/sentry-react-native/pull/5940))
- 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))
- Rename `FeedbackWidget` to `FeedbackForm` and `showFeedbackWidget` to `showFeedbackForm` ([#5931](https://github.com/getsentry/sentry-react-native/pull/5931))
- The old names are deprecated but still work
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export { SDK_NAME, SDK_VERSION } from './version';
export type { ReactNativeOptions, NativeLogEntry } from './options';
export { ReactNativeClient } from './client';

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

export {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { shouldEnableNativeNagger } from './options';
import { enableSyncToNative } from './scopeSync';
import { TouchEventBoundary } from './touchevents';
import { ReactNativeProfiler } from './tracing';
import { _appLoaded } from './tracing/integrations/appStart';
import { useEncodePolyfill } from './transports/encodePolyfill';
import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native';
import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer, isWeb } from './utils/environment';
Expand Down Expand Up @@ -220,6 +221,31 @@ export function nativeCrash(): void {
NATIVE.nativeCrash();
}

/**
* Signals that the application has finished loading and is ready for user interaction.
*
* Call this when your app is truly ready — after async initialization, data loading,
* splash screen dismissal, auth session restore, etc. This marks the end of the app start span,
* giving you a more accurate measurement of perceived startup time.
*
* If not called, the SDK falls back to the root component mount time (via `Sentry.wrap()`)
* or JS bundle execution start.
*
* @experimental This API is subject to change in future versions.
*
* @example
* ```ts
* await loadRemoteConfig();
* await restoreSession();
* SplashScreen.hide();
* Sentry.appLoaded();
* ```
*/
export function appLoaded(): void {
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
_appLoaded();
}

/**
* Flushes all pending events in the queue to disk.
* Use this before applying any realtime updates such as code-push or expo updates.
Expand Down
141 changes: 137 additions & 4 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const INTEGRATION_NAME = 'AppStart';

export type AppStartIntegration = Integration & {
captureStandaloneAppStart: () => Promise<void>;
resetAppStartDataFlushed: () => void;
cancelDeferredStandaloneCapture: () => void;
scheduleDeferredStandaloneCapture: () => void;
};

/**
Expand All @@ -59,24 +62,81 @@ interface AppStartEndData {

let appStartEndData: AppStartEndData | undefined = undefined;
let isRecordedAppStartEndTimestampMsManual = false;
let isAppLoadedManuallyInvoked = false;

let rootComponentCreationTimestampMs: number | undefined = undefined;
let isRootComponentCreationTimestampMsManual = false;

/**
* Records the application start end.
* Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`.
*
* @deprecated Use {@link appLoaded} from the public SDK API instead (`Sentry.appLoaded()`).
*/
export function captureAppStart(): Promise<void> {
return _captureAppStart({ isManual: true });
}

/**
* Signals that the app has finished loading and is ready for user interaction.
* Called internally by `appLoaded()` from the public SDK API.
*
* @private
*/
export async function _appLoaded(): Promise<void> {
if (isAppLoadedManuallyInvoked) {
debug.warn('[AppStart] appLoaded() was already called. Subsequent calls are ignored.');
return;
}

const client = getClient();
if (!client) {
debug.warn('[AppStart] appLoaded() was called before Sentry.init(). App start end will not be recorded.');
return;
}

isAppLoadedManuallyInvoked = true;

const timestampMs = timestampInSeconds() * 1000;

// If auto-capture already ran (ReactNativeProfiler.componentDidMount), overwrite the timestamp.
// The transaction hasn't been sent yet in non-standalone mode so this is safe.
if (appStartEndData) {
debug.log('[AppStart] appLoaded() overwriting auto-detected app start end timestamp.');
appStartEndData.timestampMs = timestampMs;
appStartEndData.endFrames = null;
} else {
_setAppStartEndData({ timestampMs, endFrames: null });
}
isRecordedAppStartEndTimestampMsManual = true;

await fetchAndUpdateEndFrames();

const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
if (integration) {
// Cancel any deferred standalone send from auto-capture — we'll send our own
// with the correct manual timestamp instead of sending two transactions.
integration.cancelDeferredStandaloneCapture();
// In standalone mode, auto-capture may have already flushed the transaction.
// Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp.
integration.resetAppStartDataFlushed();
await integration.captureStandaloneAppStart();
}
}

/**
* For internal use only.
*
* @private
*/
export async function _captureAppStart({ isManual }: { isManual: boolean }): Promise<void> {
// If appLoaded() was already called manually, skip the auto-capture to avoid
// overwriting the manual end timestamp (race B: appLoaded before componentDidMount).
if (!isManual && isAppLoadedManuallyInvoked) {
debug.log('[AppStart] Skipping auto app start capture because appLoaded() was already called.');
return;
}

const client = getClient();
if (!client) {
debug.warn('[AppStart] Could not capture App Start, missing client.');
Expand All @@ -94,6 +154,26 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
endFrames: null,
});

await fetchAndUpdateEndFrames();

const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
if (integration) {
if (!isManual) {
// For auto-capture, defer the standalone send to give appLoaded() a chance
// to override the end timestamp before the transaction is sent.
// If appLoaded() is called, it cancels this deferred send and sends its own.
// In non-standalone mode, scheduleDeferredStandaloneCapture is a no-op.
integration.scheduleDeferredStandaloneCapture();
} else {
await integration.captureStandaloneAppStart();
}
}
}

/**
* Fetches native frames data and attaches it to the current app start end data.
*/
async function fetchAndUpdateEndFrames(): Promise<void> {
if (NATIVE.enableNative) {
try {
const endFrames = await NATIVE.fetchNativeFrames();
Expand All @@ -103,8 +183,6 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
debug.log('[AppStart] Failed to capture end frames for app start.', error);
}
}

await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.captureStandaloneAppStart();
}

/**
Expand Down Expand Up @@ -160,6 +238,17 @@ export function _clearRootComponentCreationTimestampMs(): void {
rootComponentCreationTimestampMs = undefined;
}

/**
* For testing purposes only.
*
* @private
*/
export function _clearAppStartEndData(): void {
appStartEndData = undefined;
isRecordedAppStartEndTimestampMsManual = false;
isAppLoadedManuallyInvoked = false;
}

/**
* Attaches frame data to a span's data object.
*/
Expand Down Expand Up @@ -203,6 +292,8 @@ export const appStartIntegration = ({
let afterAllSetupCalled = false;
let firstStartedActiveRootSpanId: string | undefined = undefined;
let firstStartedActiveRootSpan: Span | undefined = undefined;
let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined;
let deferredStandaloneTimeout: ReturnType<typeof setTimeout> | undefined = undefined;

const setup = (client: Client): void => {
_client = client;
Expand Down Expand Up @@ -230,6 +321,12 @@ export const appStartIntegration = ({
appStartDataFlushed = false;
firstStartedActiveRootSpanId = undefined;
firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
cachedNativeAppStart = undefined;
if (deferredStandaloneTimeout !== undefined) {
clearTimeout(deferredStandaloneTimeout);
deferredStandaloneTimeout = undefined;
}
} else {
debug.log(
'[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.',
Expand Down Expand Up @@ -395,13 +492,23 @@ export const appStartIntegration = ({

// All failure paths below set appStartDataFlushed = true to prevent
// wasteful retries — these conditions won't change within the same app start.
const appStart = await NATIVE.fetchNativeAppStart();
//
// Use cached response if available (e.g. when _appLoaded() re-triggers
// standalone capture after auto-capture already fetched from the native layer).
// The native layer sets has_fetched = true after the first fetch, so a second
// NATIVE.fetchNativeAppStart() call would incorrectly bail out.
const isCached = cachedNativeAppStart !== undefined;
const appStart = isCached ? cachedNativeAppStart : await NATIVE.fetchNativeAppStart();
cachedNativeAppStart = appStart;
if (!appStart) {
debug.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.');
appStartDataFlushed = true;
return;
}
if (appStart.has_fetched) {
// Skip the has_fetched check when using a cached response — the native layer
// sets has_fetched = true after the first fetch, but we intentionally re-use
// the data when _appLoaded() overrides the app start end timestamp.
if (!isCached && appStart.has_fetched) {
debug.warn('[AppStart] Measured app start metrics were already reported from the native layer.');
appStartDataFlushed = true;
return;
Expand Down Expand Up @@ -540,12 +647,38 @@ export const appStartIntegration = ({
);
}

const resetAppStartDataFlushed = (): void => {
appStartDataFlushed = false;
};

const cancelDeferredStandaloneCapture = (): void => {
if (deferredStandaloneTimeout !== undefined) {
clearTimeout(deferredStandaloneTimeout);
deferredStandaloneTimeout = undefined;
debug.log('[AppStart] Cancelled deferred standalone app start capture.');
}
};

const scheduleDeferredStandaloneCapture = (): void => {
if (!standalone) {
return;
}
deferredStandaloneTimeout = setTimeout(() => {
deferredStandaloneTimeout = undefined;
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
captureStandaloneAppStart();
}, 0);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred standalone capture races with appLoaded causing duplicates

High Severity

In standalone mode, scheduleDeferredStandaloneCapture uses setTimeout(0) to defer the auto-capture send. This fires at the next macrotask — essentially immediately. In production, ReactNativeProfiler calls _captureAppStart as a floating promise; once it completes and schedules the timeout, the timeout fires long before the user calls appLoaded() (which happens after async work like config loading). When _appLoaded later runs, cancelDeferredStandaloneCapture finds nothing to cancel, then resetAppStartDataFlushed + captureStandaloneAppStart sends a second standalone transaction. The test at line 1246 only passes because _appLoaded is called immediately after the awaited _captureAppStart, allowing microtask-resolved mock promises to run cancelDeferredStandaloneCapture before the setTimeout(0) macrotask fires — a timing that doesn't reflect real usage.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks valid but it might be an edge case not affecting the reported use case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think the combination of standalone + Sentry.wrap() + appLoaded() after significant async work is a bit of a rare case 😅 In the primary use case (non-standalone), processEvent naturally defers attachment to the first navigation transaction, and appLoaded() works correctly.


return {
name: INTEGRATION_NAME,
setup,
afterAllSetup,
processEvent,
captureStandaloneAppStart,
resetAppStartDataFlushed,
cancelDeferredStandaloneCapture,
scheduleDeferredStandaloneCapture,
setFirstStartedActiveRootSpanId,
} as AppStartIntegration;
};
Expand Down
Loading
Loading