feat(core): Add Sentry.appLoaded() API to signal app start end#5940
feat(core): Add Sentry.appLoaded() API to signal app start end#5940
Conversation
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().
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
Plus 12 more 🤖 This preview updates automatically when you update the PR. |
b12e8e8 to
d7e01e6
Compare
|
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().
antonis
left a comment
There was a problem hiding this comment.
Connecting this to getsentry/sentry-cocoa#6886 for alignment as discussed on Slack
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.
bb7375b to
5b204e2
Compare
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.
…tions 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| // oxlint-disable-next-line typescript-eslint(no-floating-promises) | ||
| captureStandaloneAppStart(); | ||
| }, 0); | ||
| }; |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
This looks valid but it might be an edge case not affecting the reported use case
There was a problem hiding this comment.
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.
Android (legacy) Performance metrics 🚀
|
iOS (legacy) Performance metrics 🚀
|
antonis
left a comment
There was a problem hiding this comment.
LGTM!
It would be nice to also cover this edge case but I think it is not blocking give that the API is experimental


📢 Type of change
📜 Description
Fixes RN-565
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.When
appLoaded()is called it:manual.app.startExisting behaviour is unchanged when
appLoaded()is not called.Usage
💚 How did you test it?
Tests were added :)
📝 Checklist
sendDefaultPIIis enabled