@@ -37,6 +37,9 @@ const INTEGRATION_NAME = 'AppStart';
3737
3838export type AppStartIntegration = Integration & {
3939 captureStandaloneAppStart : ( ) => Promise < void > ;
40+ resetAppStartDataFlushed : ( ) => void ;
41+ cancelDeferredStandaloneCapture : ( ) => void ;
42+ scheduleDeferredStandaloneCapture : ( ) => void ;
4043} ;
4144
4245/**
@@ -59,24 +62,81 @@ interface AppStartEndData {
5962
6063let appStartEndData : AppStartEndData | undefined = undefined ;
6164let isRecordedAppStartEndTimestampMsManual = false ;
65+ let isAppLoadedManuallyInvoked = false ;
6266
6367let rootComponentCreationTimestampMs : number | undefined = undefined ;
6468let isRootComponentCreationTimestampMsManual = false ;
6569
6670/**
6771 * Records the application start end.
6872 * Used automatically by `Sentry.wrap` and `Sentry.ReactNativeProfiler`.
73+ *
74+ * @deprecated Use {@link appLoaded} from the public SDK API instead (`Sentry.appLoaded()`).
6975 */
7076export function captureAppStart ( ) : Promise < void > {
7177 return _captureAppStart ( { isManual : true } ) ;
7278}
7379
80+ /**
81+ * Signals that the app has finished loading and is ready for user interaction.
82+ * Called internally by `appLoaded()` from the public SDK API.
83+ *
84+ * @private
85+ */
86+ export async function _appLoaded ( ) : Promise < void > {
87+ if ( isAppLoadedManuallyInvoked ) {
88+ debug . warn ( '[AppStart] appLoaded() was already called. Subsequent calls are ignored.' ) ;
89+ return ;
90+ }
91+
92+ const client = getClient ( ) ;
93+ if ( ! client ) {
94+ debug . warn ( '[AppStart] appLoaded() was called before Sentry.init(). App start end will not be recorded.' ) ;
95+ return ;
96+ }
97+
98+ isAppLoadedManuallyInvoked = true ;
99+
100+ const timestampMs = timestampInSeconds ( ) * 1000 ;
101+
102+ // If auto-capture already ran (ReactNativeProfiler.componentDidMount), overwrite the timestamp.
103+ // The transaction hasn't been sent yet in non-standalone mode so this is safe.
104+ if ( appStartEndData ) {
105+ debug . log ( '[AppStart] appLoaded() overwriting auto-detected app start end timestamp.' ) ;
106+ appStartEndData . timestampMs = timestampMs ;
107+ appStartEndData . endFrames = null ;
108+ } else {
109+ _setAppStartEndData ( { timestampMs, endFrames : null } ) ;
110+ }
111+ isRecordedAppStartEndTimestampMsManual = true ;
112+
113+ await fetchAndUpdateEndFrames ( ) ;
114+
115+ const integration = client . getIntegrationByName < AppStartIntegration > ( INTEGRATION_NAME ) ;
116+ if ( integration ) {
117+ // Cancel any deferred standalone send from auto-capture — we'll send our own
118+ // with the correct manual timestamp instead of sending two transactions.
119+ integration . cancelDeferredStandaloneCapture ( ) ;
120+ // In standalone mode, auto-capture may have already flushed the transaction.
121+ // Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp.
122+ integration . resetAppStartDataFlushed ( ) ;
123+ await integration . captureStandaloneAppStart ( ) ;
124+ }
125+ }
126+
74127/**
75128 * For internal use only.
76129 *
77130 * @private
78131 */
79132export async function _captureAppStart ( { isManual } : { isManual : boolean } ) : Promise < void > {
133+ // If appLoaded() was already called manually, skip the auto-capture to avoid
134+ // overwriting the manual end timestamp (race B: appLoaded before componentDidMount).
135+ if ( ! isManual && isAppLoadedManuallyInvoked ) {
136+ debug . log ( '[AppStart] Skipping auto app start capture because appLoaded() was already called.' ) ;
137+ return ;
138+ }
139+
80140 const client = getClient ( ) ;
81141 if ( ! client ) {
82142 debug . warn ( '[AppStart] Could not capture App Start, missing client.' ) ;
@@ -94,6 +154,26 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
94154 endFrames : null ,
95155 } ) ;
96156
157+ await fetchAndUpdateEndFrames ( ) ;
158+
159+ const integration = client . getIntegrationByName < AppStartIntegration > ( INTEGRATION_NAME ) ;
160+ if ( integration ) {
161+ if ( ! isManual ) {
162+ // For auto-capture, defer the standalone send to give appLoaded() a chance
163+ // to override the end timestamp before the transaction is sent.
164+ // If appLoaded() is called, it cancels this deferred send and sends its own.
165+ // In non-standalone mode, scheduleDeferredStandaloneCapture is a no-op.
166+ integration . scheduleDeferredStandaloneCapture ( ) ;
167+ } else {
168+ await integration . captureStandaloneAppStart ( ) ;
169+ }
170+ }
171+ }
172+
173+ /**
174+ * Fetches native frames data and attaches it to the current app start end data.
175+ */
176+ async function fetchAndUpdateEndFrames ( ) : Promise < void > {
97177 if ( NATIVE . enableNative ) {
98178 try {
99179 const endFrames = await NATIVE . fetchNativeFrames ( ) ;
@@ -103,8 +183,6 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
103183 debug . log ( '[AppStart] Failed to capture end frames for app start.' , error ) ;
104184 }
105185 }
106-
107- await client . getIntegrationByName < AppStartIntegration > ( INTEGRATION_NAME ) ?. captureStandaloneAppStart ( ) ;
108186}
109187
110188/**
@@ -160,6 +238,17 @@ export function _clearRootComponentCreationTimestampMs(): void {
160238 rootComponentCreationTimestampMs = undefined ;
161239}
162240
241+ /**
242+ * For testing purposes only.
243+ *
244+ * @private
245+ */
246+ export function _clearAppStartEndData ( ) : void {
247+ appStartEndData = undefined ;
248+ isRecordedAppStartEndTimestampMsManual = false ;
249+ isAppLoadedManuallyInvoked = false ;
250+ }
251+
163252/**
164253 * Attaches frame data to a span's data object.
165254 */
@@ -203,6 +292,8 @@ export const appStartIntegration = ({
203292 let afterAllSetupCalled = false ;
204293 let firstStartedActiveRootSpanId : string | undefined = undefined ;
205294 let firstStartedActiveRootSpan : Span | undefined = undefined ;
295+ let cachedNativeAppStart : NativeAppStartResponse | null | undefined = undefined ;
296+ let deferredStandaloneTimeout : ReturnType < typeof setTimeout > | undefined = undefined ;
206297
207298 const setup = ( client : Client ) : void => {
208299 _client = client ;
@@ -230,6 +321,12 @@ export const appStartIntegration = ({
230321 appStartDataFlushed = false ;
231322 firstStartedActiveRootSpanId = undefined ;
232323 firstStartedActiveRootSpan = undefined ;
324+ isAppLoadedManuallyInvoked = false ;
325+ cachedNativeAppStart = undefined ;
326+ if ( deferredStandaloneTimeout !== undefined ) {
327+ clearTimeout ( deferredStandaloneTimeout ) ;
328+ deferredStandaloneTimeout = undefined ;
329+ }
233330 } else {
234331 debug . log (
235332 '[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.' ,
@@ -395,13 +492,23 @@ export const appStartIntegration = ({
395492
396493 // All failure paths below set appStartDataFlushed = true to prevent
397494 // wasteful retries — these conditions won't change within the same app start.
398- const appStart = await NATIVE . fetchNativeAppStart ( ) ;
495+ //
496+ // Use cached response if available (e.g. when _appLoaded() re-triggers
497+ // standalone capture after auto-capture already fetched from the native layer).
498+ // The native layer sets has_fetched = true after the first fetch, so a second
499+ // NATIVE.fetchNativeAppStart() call would incorrectly bail out.
500+ const isCached = cachedNativeAppStart !== undefined ;
501+ const appStart = isCached ? cachedNativeAppStart : await NATIVE . fetchNativeAppStart ( ) ;
502+ cachedNativeAppStart = appStart ;
399503 if ( ! appStart ) {
400504 debug . warn ( '[AppStart] Failed to retrieve the app start metrics from the native layer.' ) ;
401505 appStartDataFlushed = true ;
402506 return ;
403507 }
404- if ( appStart . has_fetched ) {
508+ // Skip the has_fetched check when using a cached response — the native layer
509+ // sets has_fetched = true after the first fetch, but we intentionally re-use
510+ // the data when _appLoaded() overrides the app start end timestamp.
511+ if ( ! isCached && appStart . has_fetched ) {
405512 debug . warn ( '[AppStart] Measured app start metrics were already reported from the native layer.' ) ;
406513 appStartDataFlushed = true ;
407514 return ;
@@ -540,12 +647,38 @@ export const appStartIntegration = ({
540647 ) ;
541648 }
542649
650+ const resetAppStartDataFlushed = ( ) : void => {
651+ appStartDataFlushed = false ;
652+ } ;
653+
654+ const cancelDeferredStandaloneCapture = ( ) : void => {
655+ if ( deferredStandaloneTimeout !== undefined ) {
656+ clearTimeout ( deferredStandaloneTimeout ) ;
657+ deferredStandaloneTimeout = undefined ;
658+ debug . log ( '[AppStart] Cancelled deferred standalone app start capture.' ) ;
659+ }
660+ } ;
661+
662+ const scheduleDeferredStandaloneCapture = ( ) : void => {
663+ if ( ! standalone ) {
664+ return ;
665+ }
666+ deferredStandaloneTimeout = setTimeout ( ( ) => {
667+ deferredStandaloneTimeout = undefined ;
668+ // oxlint-disable-next-line typescript-eslint(no-floating-promises)
669+ captureStandaloneAppStart ( ) ;
670+ } , 0 ) ;
671+ } ;
672+
543673 return {
544674 name : INTEGRATION_NAME ,
545675 setup,
546676 afterAllSetup,
547677 processEvent,
548678 captureStandaloneAppStart,
679+ resetAppStartDataFlushed,
680+ cancelDeferredStandaloneCapture,
681+ scheduleDeferredStandaloneCapture,
549682 setFirstStartedActiveRootSpanId,
550683 } as AppStartIntegration ;
551684} ;
0 commit comments