Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 {
Comment thread
antonis marked this conversation as resolved.
// 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
82 changes: 80 additions & 2 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const INTEGRATION_NAME = 'AppStart';

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

/**
Expand All @@ -59,24 +60,78 @@ interface AppStartEndData {

let appStartEndData: AppStartEndData | undefined = undefined;
let isRecordedAppStartEndTimestampMsManual = false;
let isAppLoadedManuallyInvoked = false;
Comment thread
cursor[bot] marked this conversation as resolved.

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> {
Comment thread
alwx marked this conversation as resolved.
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

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) {
// 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();
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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 +149,14 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
endFrames: null,
});

await fetchAndUpdateEndFrames();
await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.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 +166,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 +221,17 @@ export function _clearRootComponentCreationTimestampMs(): void {
rootComponentCreationTimestampMs = undefined;
}

/**
* For testing purposes only.
*
* @private
*/
export function _clearAppStartEndData(): void {
appStartEndData = undefined;
Comment thread
sentry[bot] marked this conversation as resolved.
isRecordedAppStartEndTimestampMsManual = false;
isAppLoadedManuallyInvoked = false;
}

/**
* Attaches frame data to a span's data object.
*/
Expand Down Expand Up @@ -230,6 +302,7 @@ export const appStartIntegration = ({
appStartDataFlushed = false;
firstStartedActiveRootSpanId = undefined;
firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
} else {
debug.log(
'[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.',
Expand Down Expand Up @@ -540,12 +613,17 @@ export const appStartIntegration = ({
);
}

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

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