From d7e01e637de3e4e5d5c6ead9323453871fb01262 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 31 Mar 2026 15:04:48 +0200 Subject: [PATCH 1/5] 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(). --- CHANGELOG.md | 4 + packages/core/src/js/index.ts | 2 +- packages/core/src/js/sdk.tsx | 24 ++++ .../src/js/tracing/integrations/appStart.ts | 64 +++++++++ .../tracing/integrations/appStart.test.ts | 131 ++++++++++++++++++ 5 files changed, 224 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaa1d1697..fcf9d7c57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Add `Sentry.appLoaded()` API to explicitly signal app start end ([#5940](https://github.com/getsentry/sentry-react-native/pull/5940)) + ### Fixes - 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)) diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index e83e8d6d54..d42b231be7 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -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 { diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 1853294ca1..40a3eaa0a8 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -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'; @@ -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 { + // 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. diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 6b728a04e4..b1478441ae 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -59,6 +59,7 @@ interface AppStartEndData { let appStartEndData: AppStartEndData | undefined = undefined; let isRecordedAppStartEndTimestampMsManual = false; +let isAppLoadedManuallyInvoked = false; let rootComponentCreationTimestampMs: number | undefined = undefined; let isRootComponentCreationTimestampMsManual = false; @@ -71,12 +72,64 @@ export function captureAppStart(): Promise { 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 { + 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; + } + + 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); + } + } + + await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); +} + /** * For internal use only. * * @private */ export async function _captureAppStart({ isManual }: { isManual: boolean }): Promise { + // 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.'); @@ -160,6 +213,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. */ diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index feb71ace53..812a83cf9f 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1,6 +1,7 @@ import type { ErrorEvent, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core'; import { + debug, getCurrentScope, getGlobalScope, getIsolationScope, @@ -24,7 +25,9 @@ import { UI_LOAD, } from '../../../src/js/tracing'; import { + _appLoaded, _captureAppStart, + _clearAppStartEndData, _clearRootComponentCreationTimestampMs, _setAppStartEndData, _setRootComponentCreationTimestampMs, @@ -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; + 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 = { From 1860e3d43e190f974a57d496e540c9d4118ae436 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 1 Apr 2026 15:32:02 +0200 Subject: [PATCH 2/5] fix(core): Address review feedback for appLoaded() API Bug fix: Move isAppLoadedManuallyInvoked flag assignment after the getClient() check. Previously, calling appLoaded() before Sentry.init() would permanently set the flag, silently disabling all automatic app start tracking for the session. Now the flag is only set when a client exists, so auto-capture via ReactNativeProfiler remains functional. Refactor: Extract shared native frames fetching logic into a dedicated fetchAndUpdateEndFrames() helper, reused by both _appLoaded() and _captureAppStart() to eliminate duplication. Mark appLoaded() as @experimental since it is a new API surface. Deprecate captureAppStart() with @deprecated JSDoc tag pointing users to the new Sentry.appLoaded() API. Add standalone mode test verifying that appLoaded() correctly triggers captureStandaloneAppStart via the integration. Add regression test confirming that auto-capture still works after appLoaded() is called before Sentry.init(). --- packages/core/src/js/sdk.tsx | 2 + .../src/js/tracing/integrations/appStart.ts | 26 +++--- .../tracing/integrations/appStart.test.ts | 89 +++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 3f844bd857..6fffe6d66f 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -231,6 +231,8 @@ export function nativeCrash(): void { * 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(); diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index b1478441ae..a29ecc6db5 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -67,6 +67,8 @@ 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 { return _captureAppStart({ isManual: true }); @@ -83,7 +85,6 @@ export async function _appLoaded(): Promise { debug.warn('[AppStart] appLoaded() was already called. Subsequent calls are ignored.'); return; } - isAppLoadedManuallyInvoked = true; const client = getClient(); if (!client) { @@ -91,6 +92,8 @@ export async function _appLoaded(): Promise { return; } + isAppLoadedManuallyInvoked = true; + const timestampMs = timestampInSeconds() * 1000; // If auto-capture already ran (ReactNativeProfiler.componentDidMount), overwrite the timestamp. @@ -104,16 +107,7 @@ export async function _appLoaded(): Promise { } 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); - } - } - + await fetchAndUpdateEndFrames(); await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); } @@ -147,6 +141,14 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro endFrames: null, }); + await fetchAndUpdateEndFrames(); + await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); +} + +/** + * Fetches native frames data and attaches it to the current app start end data. + */ +async function fetchAndUpdateEndFrames(): Promise { if (NATIVE.enableNative) { try { const endFrames = await NATIVE.fetchNativeFrames(); @@ -156,8 +158,6 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro debug.log('[AppStart] Failed to capture end frames for app start.', error); } } - - await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); } /** diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 812a83cf9f..24db228b86 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1153,6 +1153,95 @@ describe('appLoaded() API', () => { setCurrentClient(client); }); + + it('does not block auto-capture when called before Sentry.init', async () => { + // Simulate appLoaded() called before init — should NOT set the flag + getCurrentScope().setClient(undefined); + getGlobalScope().setClient(undefined); + getIsolationScope().setClient(undefined); + + await _appLoaded(); + + setCurrentClient(client); + + // Auto-capture should still work because the flag was not set + const autoTimeSeconds = Date.now() / 1000; + mockFunction(timestampInSeconds).mockReturnValueOnce(autoTimeSeconds); + await _captureAppStart({ isManual: false }); + + const appStartTimeMilliseconds = autoTimeSeconds * 1000 - 3000; + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ + type: 'cold' as const, + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: false, + spans: [], + }); + + const integration = makeIntegration(); + integration.setFirstStartedActiveRootSpanId('span-after-init'); + + const event: TransactionEvent = { + type: 'transaction', + start_timestamp: autoTimeSeconds, + timestamp: autoTimeSeconds + 2, + contexts: { trace: { span_id: 'span-after-init', 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).toBeDefined(); + expect(appStartSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); + }); + +}); + +describe('appLoaded() standalone mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + _clearAppStartEndData(); + _clearRootComponentCreationTimestampMs(); + mockReactNativeBundleExecutionStartTimestamp(); + }); + + afterEach(() => { + clearReactNativeBundleExecutionStartTimestamp(); + _clearAppStartEndData(); + _clearRootComponentCreationTimestampMs(); + }); + + it('triggers standalone app start capture via appLoaded()', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const [, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + const appLoadedTimeSeconds = Date.now() / 1000 + 0.5; + mockFunction(timestampInSeconds).mockReturnValue(appLoadedTimeSeconds); + + await _appLoaded(); + // Flush async event processing triggered by scope.captureEvent + await new Promise(resolve => setTimeout(resolve, 0)); + + const actualEvent = standaloneClient.event; + expect(actualEvent).toBeDefined(); + + const appStartSpan = actualEvent?.spans?.find(s => s.op === APP_START_COLD_OP); + expect(appStartSpan).toBeDefined(); + expect(appStartSpan?.timestamp).toBeCloseTo(appLoadedTimeSeconds, 1); + expect(appStartSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + expect(appStartSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); + }); }); describe('Frame Data Integration', () => { From 5b204e28f883d7589c5401bd6756475b76994ebf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 2 Apr 2026 10:28:58 +0200 Subject: [PATCH 3/5] fix(core): Fix standalone mode and runApplication reset for appLoaded() Fix two bugs identified in the Cursor Bugbot review: 1. Standalone mode: appLoaded() loses manual timestamp (High) When auto-capture from ReactNativeProfiler runs first in standalone mode, it sends the transaction and sets appStartDataFlushed = true. A subsequent appLoaded() call would find the flag set and silently skip re-sending. Fix: add resetAppStartDataFlushed() method on the AppStartIntegration type, called by _appLoaded() before invoking captureStandaloneAppStart(). This allows the standalone transaction to be re-sent with the correct manual timestamp. 2. isAppLoadedManuallyInvoked not reset on runApplication (Medium) The onRunApplication callback resets appStartDataFlushed and span tracking state for subsequent app starts (e.g. Android activity recreation), but did not reset isAppLoadedManuallyInvoked. After the first appLoaded() call, all subsequent app starts would have auto-capture permanently blocked. Fix: reset the flag alongside the other state in the onRunApplication callback. Adds two tests: standalone override after auto-capture, and flag reset allowing auto-capture on subsequent app starts. --- .../src/js/tracing/integrations/appStart.ts | 16 +++- .../tracing/integrations/appStart.test.ts | 91 ++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 4e16704645..cb1308f82a 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -37,6 +37,7 @@ const INTEGRATION_NAME = 'AppStart'; export type AppStartIntegration = Integration & { captureStandaloneAppStart: () => Promise; + resetAppStartDataFlushed: () => void; }; /** @@ -108,7 +109,14 @@ export async function _appLoaded(): Promise { isRecordedAppStartEndTimestampMsManual = true; await fetchAndUpdateEndFrames(); - await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); + + const integration = client.getIntegrationByName(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(); + } } /** @@ -294,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.', @@ -604,12 +613,17 @@ export const appStartIntegration = ({ ); } + const resetAppStartDataFlushed = (): void => { + appStartDataFlushed = false; + }; + return { name: INTEGRATION_NAME, setup, afterAllSetup, processEvent, captureStandaloneAppStart, + resetAppStartDataFlushed, setFirstStartedActiveRootSpanId, } as AppStartIntegration; }; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index eb2dd60f11..e588c22459 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1193,7 +1193,6 @@ describe('appLoaded() API', () => { expect(appStartSpan).toBeDefined(); expect(appStartSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); }); - }); describe('appLoaded() standalone mode', () => { @@ -1243,6 +1242,96 @@ describe('appLoaded() standalone mode', () => { expect(appStartSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); expect(appStartSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); }); + + it('overrides already-flushed standalone transaction when appLoaded() is called after auto-capture', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const [, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + // Simulate auto-capture from ReactNativeProfiler (componentDidMount) + const autoTimeSeconds = Date.now() / 1000; + mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds); + await _captureAppStart({ isManual: false }); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Auto-capture should have sent the standalone transaction + expect(standaloneClient.eventQueue.length).toBe(1); + const autoEvent = standaloneClient.eventQueue[0]; + const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP); + expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); + + // Now call appLoaded() with a later timestamp + const manualTimeSeconds = autoTimeSeconds + 2; + mockFunction(timestampInSeconds).mockReturnValue(manualTimeSeconds); + await _appLoaded(); + await new Promise(resolve => setTimeout(resolve, 0)); + + // appLoaded() should have sent a second standalone transaction with the manual timestamp + expect(standaloneClient.eventQueue.length).toBe(2); + const manualEvent = standaloneClient.eventQueue[1]; + const manualSpan = manualEvent?.spans?.find(s => s.op === APP_START_COLD_OP); + expect(manualSpan).toBeDefined(); + expect(manualSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1); + expect(manualSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + expect(manualSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); + }); + + it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + mockAppStart({ cold: true }); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + // First app start: call appLoaded() + const firstTimeSeconds = Date.now() / 1000; + mockFunction(timestampInSeconds).mockReturnValue(firstTimeSeconds); + await _appLoaded(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(standaloneClient.eventQueue.length).toBe(1); + + // Verify auto-capture is blocked while flag is set + const logSpy = jest.spyOn(debug, 'log'); + await _captureAppStart({ isManual: false }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping auto app start capture')); + + // Simulate what onRunApplication does: reset flags for a new app start + _clearAppStartEndData(); + mockAppStart({ cold: true }); + + // Now auto-capture should work again + logSpy.mockClear(); + const autoTimeSeconds = Date.now() / 1000 + 1; + mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds); + await _captureAppStart({ isManual: false }); + + const skippedCalls = logSpy.mock.calls.filter( + call => typeof call[0] === 'string' && call[0].includes('Skipping auto app start capture'), + ); + expect(skippedCalls.length).toBe(0); + }); }); describe('Frame Data Integration', () => { From 456fa3cd665ec8618a022efd5eb4fe5900dee969 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 2 Apr 2026 10:38:54 +0200 Subject: [PATCH 4/5] fix(core): Cache native app start response to fix standalone re-capture In standalone mode, when _appLoaded() re-triggers captureStandaloneAppStart after auto-capture already ran, the second NATIVE.fetchNativeAppStart() call returns has_fetched: true from the native layer. This caused attachAppStartToTransactionEvent to bail out, silently dropping the manual timestamp. Fix: cache the NativeAppStartResponse in the integration closure on first fetch. Subsequent calls to attachAppStartToTransactionEvent reuse the cached response and skip the has_fetched check. The cache is reset in the onRunApplication callback alongside other state. Updates the standalone override test to mock has_fetched: true on the second native call, proving the cache works in production scenarios. --- .../core/src/js/tracing/integrations/appStart.ts | 16 ++++++++++++++-- .../test/tracing/integrations/appStart.test.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index cb1308f82a..5737b195da 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -275,6 +275,7 @@ export const appStartIntegration = ({ let afterAllSetupCalled = false; let firstStartedActiveRootSpanId: string | undefined = undefined; let firstStartedActiveRootSpan: Span | undefined = undefined; + let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined; const setup = (client: Client): void => { _client = client; @@ -303,6 +304,7 @@ export const appStartIntegration = ({ firstStartedActiveRootSpanId = undefined; firstStartedActiveRootSpan = undefined; isAppLoadedManuallyInvoked = false; + cachedNativeAppStart = undefined; } else { debug.log( '[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.', @@ -468,13 +470,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; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index e588c22459..d8d66675e6 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1272,6 +1272,15 @@ describe('appLoaded() standalone mode', () => { const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP); expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); + // Simulate the native layer returning has_fetched: true after the first fetch. + // _appLoaded() must use the cached response to avoid bailing out. + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ + type: 'cold' as const, + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: true, + spans: [], + }); + // Now call appLoaded() with a later timestamp const manualTimeSeconds = autoTimeSeconds + 2; mockFunction(timestampInSeconds).mockReturnValue(manualTimeSeconds); From 4b535bf061564883faadb579debb8efaecc83a7e Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 2 Apr 2026 10:53:14 +0200 Subject: [PATCH 5/5] fix(core): Defer standalone app start send to avoid duplicate transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When appLoaded() is called after auto-capture in standalone mode, two separate App Start transactions were sent to Sentry for the same launch. This inflates transaction counts and produces inconsistent app start metrics on the dashboard. Fix: in standalone mode, auto-capture from ReactNativeProfiler now defers the captureStandaloneAppStart() call via setTimeout(0) instead of sending immediately. This gives appLoaded() a chance to cancel the deferred send and replace it with a single transaction using the correct manual timestamp. If appLoaded() is never called, the deferred send fires on the next tick and the existing behavior is preserved — only one transaction is sent. Adds scheduleDeferredStandaloneCapture() and cancelDeferredStandaloneCapture() methods on the AppStartIntegration type. Adds a test verifying the deferred send fires when appLoaded() is not called. --- .../src/js/tracing/integrations/appStart.ts | 45 +++++++++++- .../tracing/integrations/appStart.test.ts | 70 +++++++++++++------ 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 5737b195da..382e26ce08 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -38,6 +38,8 @@ const INTEGRATION_NAME = 'AppStart'; export type AppStartIntegration = Integration & { captureStandaloneAppStart: () => Promise; resetAppStartDataFlushed: () => void; + cancelDeferredStandaloneCapture: () => void; + scheduleDeferredStandaloneCapture: () => void; }; /** @@ -112,6 +114,9 @@ export async function _appLoaded(): Promise { const integration = client.getIntegrationByName(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(); @@ -150,7 +155,19 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro }); await fetchAndUpdateEndFrames(); - await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); + + const integration = client.getIntegrationByName(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(); + } + } } /** @@ -276,6 +293,7 @@ export const appStartIntegration = ({ let firstStartedActiveRootSpanId: string | undefined = undefined; let firstStartedActiveRootSpan: Span | undefined = undefined; let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined; + let deferredStandaloneTimeout: ReturnType | undefined = undefined; const setup = (client: Client): void => { _client = client; @@ -305,6 +323,10 @@ export const appStartIntegration = ({ 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.', @@ -629,6 +651,25 @@ export const appStartIntegration = ({ 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); + }; + return { name: INTEGRATION_NAME, setup, @@ -636,6 +677,8 @@ export const appStartIntegration = ({ processEvent, captureStandaloneAppStart, resetAppStartDataFlushed, + cancelDeferredStandaloneCapture, + scheduleDeferredStandaloneCapture, setFirstStartedActiveRootSpanId, } as AppStartIntegration; }; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index d8d66675e6..2b76227044 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1260,36 +1260,25 @@ describe('appLoaded() standalone mode', () => { integration.setup(standaloneClient); standaloneClient.addIntegration(integration); - // Simulate auto-capture from ReactNativeProfiler (componentDidMount) + // Simulate auto-capture from ReactNativeProfiler (componentDidMount). + // In standalone mode, auto-capture defers the send to give appLoaded() a chance. const autoTimeSeconds = Date.now() / 1000; mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds); await _captureAppStart({ isManual: false }); - await new Promise(resolve => setTimeout(resolve, 0)); - - // Auto-capture should have sent the standalone transaction - expect(standaloneClient.eventQueue.length).toBe(1); - const autoEvent = standaloneClient.eventQueue[0]; - const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP); - expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); - // Simulate the native layer returning has_fetched: true after the first fetch. - // _appLoaded() must use the cached response to avoid bailing out. - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ - type: 'cold' as const, - app_start_timestamp_ms: appStartTimeMilliseconds, - has_fetched: true, - spans: [], - }); + // No transaction sent yet — the standalone send is deferred + expect(standaloneClient.eventQueue.length).toBe(0); - // Now call appLoaded() with a later timestamp + // Now call appLoaded() with a later timestamp — this cancels the deferred + // auto-capture send and sends only one transaction with the manual timestamp. const manualTimeSeconds = autoTimeSeconds + 2; mockFunction(timestampInSeconds).mockReturnValue(manualTimeSeconds); await _appLoaded(); await new Promise(resolve => setTimeout(resolve, 0)); - // appLoaded() should have sent a second standalone transaction with the manual timestamp - expect(standaloneClient.eventQueue.length).toBe(2); - const manualEvent = standaloneClient.eventQueue[1]; + // Only one transaction should be sent — the manual one + expect(standaloneClient.eventQueue.length).toBe(1); + const manualEvent = standaloneClient.eventQueue[0]; const manualSpan = manualEvent?.spans?.find(s => s.op === APP_START_COLD_OP); expect(manualSpan).toBeDefined(); expect(manualSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1); @@ -1297,6 +1286,47 @@ describe('appLoaded() standalone mode', () => { expect(manualSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); }); + it('sends deferred standalone transaction when appLoaded() is not called', async () => { + jest.useFakeTimers(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const [, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + const autoTimeSeconds = Date.now() / 1000; + mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds); + await _captureAppStart({ isManual: false }); + + // No transaction yet — deferred + expect(standaloneClient.eventQueue.length).toBe(0); + + // Advance timers to fire the deferred send, then switch to real timers + // so the async captureStandaloneAppStart() can complete naturally. + jest.runAllTimers(); + jest.useRealTimers(); + // Flush async event processing (captureStandaloneAppStart has multiple await steps) + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(standaloneClient.eventQueue.length).toBe(1); + const autoEvent = standaloneClient.eventQueue[0]; + const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP); + expect(autoSpan).toBeDefined(); + expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); + expect(autoSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + }); + it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => { getCurrentScope().clear(); getIsolationScope().clear();