Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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))
- 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
- Deprecate `FeedbackButton`, `showFeedbackButton`, and `hideFeedbackButton` ([#5933](https://github.com/getsentry/sentry-react-native/pull/5933))
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
24 changes: 24 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,29 @@ 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.
*
* @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
64 changes: 64 additions & 0 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ 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;
Expand All @@ -71,12 +72,64 @@ 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;
}
isAppLoadedManuallyInvoked = true;

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.
}
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated

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;

if (NATIVE.enableNative) {
try {
const endFrames = await NATIVE.fetchNativeFrames();
debug.log('[AppStart] Captured end frames for app start.', endFrames);
_updateAppStartEndFrames(endFrames);
} catch (error) {
debug.log('[AppStart] Failed to capture end frames for app start.', error);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.captureStandaloneAppStart();
Comment thread
alwx marked this conversation as resolved.
Outdated
}
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 Down Expand Up @@ -160,6 +213,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
131 changes: 131 additions & 0 deletions packages/core/test/tracing/integrations/appStart.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ErrorEvent, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core';

import {
debug,
getCurrentScope,
getGlobalScope,
getIsolationScope,
Expand All @@ -24,7 +25,9 @@ import {
UI_LOAD,
} from '../../../src/js/tracing';
import {
_appLoaded,
_captureAppStart,
_clearAppStartEndData,
_clearRootComponentCreationTimestampMs,
_setAppStartEndData,
_setRootComponentCreationTimestampMs,
Expand Down Expand Up @@ -1024,6 +1027,134 @@ describe('App Start Integration', () => {
});
});

describe('appLoaded() API', () => {
let client: TestClient;

beforeEach(() => {
jest.clearAllMocks();
_clearAppStartEndData();
_clearRootComponentCreationTimestampMs();
mockReactNativeBundleExecutionStartTimestamp();
client = new TestClient({
...getDefaultTestClientOptions(),
enableAppStartTracking: true,
tracesSampleRate: 1.0,
});
setCurrentClient(client);
client.init();
});

afterEach(() => {
clearReactNativeBundleExecutionStartTimestamp();
_clearAppStartEndData();
_clearRootComponentCreationTimestampMs();
});

function makeIntegration(): AppStartIntegrationTest {
const integration = appStartIntegration({ standalone: false }) as AppStartIntegrationTest;
Comment thread
alwx marked this conversation as resolved.
integration.setup(client);
integration.afterAllSetup(client);
return integration;
}

it('sets the app start end timestamp and marks it as manual', async () => {
const appLoadedTimeSeconds = Date.now() / 1000;
mockFunction(timestampInSeconds).mockReturnValue(appLoadedTimeSeconds);

await _appLoaded();

const appStartTimeMilliseconds = appLoadedTimeSeconds * 1000 - 3000;
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({
type: 'cold' as const,
app_start_timestamp_ms: appStartTimeMilliseconds,
has_fetched: false,
spans: [],
});

const integration = makeIntegration();
integration.setFirstStartedActiveRootSpanId('test-span-id');

const event: TransactionEvent = {
type: 'transaction',
start_timestamp: Date.now() / 1000,
timestamp: Date.now() / 1000 + 1,
contexts: { trace: { span_id: 'test-span-id', trace_id: 'trace123' } },
};

const processed = (await integration.processEvent(event, {}, client)) as TransactionEvent;
const appStartSpan = processed.spans?.find(s => s.op === APP_START_COLD_OP);

expect(appStartSpan).toBeDefined();
expect(appStartSpan?.timestamp).toBeCloseTo(appLoadedTimeSeconds, 1);
expect(appStartSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START);
});

it('ignores subsequent calls after first invocation', async () => {
const warnSpy = jest.spyOn(debug, 'warn');

await _appLoaded();
await _appLoaded();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('already called'));
});

it('overrides auto-detected timestamp when called after _captureAppStart', async () => {
const now = Date.now();
const autoTimeSeconds = now / 1000;
const manualTimeSeconds = now / 1000 + 0.5;
const appStartTimeMilliseconds = now - 3000;

mockFunction(timestampInSeconds).mockReturnValueOnce(autoTimeSeconds);
await _captureAppStart({ isManual: false });

mockFunction(timestampInSeconds).mockReturnValueOnce(manualTimeSeconds);
await _appLoaded();

mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({
type: 'cold' as const,
app_start_timestamp_ms: appStartTimeMilliseconds,
has_fetched: false,
spans: [],
});

const integration = makeIntegration();
integration.setFirstStartedActiveRootSpanId('span-override');

const event: TransactionEvent = {
type: 'transaction',
start_timestamp: now / 1000,
timestamp: now / 1000 + 2,
contexts: { trace: { span_id: 'span-override', trace_id: 'trace' } },
};

const processed = (await integration.processEvent(event, {}, client)) as TransactionEvent;
const appStartSpan = processed.spans?.find(s => s.op === APP_START_COLD_OP);
expect(appStartSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1);
});

it('prevents auto-capture from overriding when called before _captureAppStart', async () => {
await _appLoaded();

const logSpy = jest.spyOn(debug, 'log');
await _captureAppStart({ isManual: false });

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping auto app start capture'));
});

it('warns and is a no-op when called before Sentry.init', async () => {
const warnSpy = jest.spyOn(debug, 'warn');
getCurrentScope().setClient(undefined);
getGlobalScope().setClient(undefined);
getIsolationScope().setClient(undefined);

await _appLoaded();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before Sentry.init'));

setCurrentClient(client);
});
});

describe('Frame Data Integration', () => {
it('attaches frame data to standalone cold app start span', async () => {
const mockEndFrames = {
Expand Down
Loading