Skip to content

feat(core): Add Sentry.appLoaded() API to signal app start end#5940

Merged
alwx merged 7 commits intomainfrom
alwx/feat/app-loaded-api
Apr 2, 2026
Merged

feat(core): Add Sentry.appLoaded() API to signal app start end#5940
alwx merged 7 commits intomainfrom
alwx/feat/app-loaded-api

Conversation

@alwx
Copy link
Copy Markdown
Contributor

@alwx alwx commented Mar 31, 2026

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 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:

  • 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
  • Marks the span origin as manual.app.start

Existing behaviour is unchanged when appLoaded() is not called.

Usage

await loadRemoteConfig();
await restoreSession();
SplashScreen.hide();
Sentry.appLoaded();

💚 How did you test it?

Tests were added :)

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

@alwx alwx self-assigned this Mar 31, 2026
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().
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(core): Add Sentry.appLoaded() API to signal app start end by alwx in #5940
  • chore: Update validate-pr workflow by stephanie-anderson in #5948
  • fix(tracing): Fix inflated http.client span durations caused by iOS inactive timer delay by antonis in #5944
  • feat(core): Add frames.delay data from native SDKs by antonis in #5907
  • docs(core): Add changelog for supabase PostgREST nullish response fix by antonis in #5939
  • chore(deps): update JavaScript SDK to v10.47.0 by github-actions in #5938
  • chore(core): Deprecate FeedbackButton FAB APIs by antonis in #5933
  • Fix: Disable global prettier by lucas-zimerman in #5937
  • refactor(core): Rename FeedbackWidget to FeedbackForm by antonis in #5931
  • refactor(core): Extract playground modal styles to separate file by antonis in #5927
  • fix(ci): Avoid unnecessary runner allocation by splitting platform matrix into separate jobs by alwx in #5924
  • feat(core): Track shake to report integration usage by antonis in #5929
  • chore(deps): update CLI to v3.3.5 by github-actions in #5925
  • chore: Replace prettier with oxfmt by antonis in #5880
  • chore(deps): bump brace-expansion to ^5.0.5 by antonis in #5920
  • chore(deps): bump path-to-regexp to ^8.4.0 by antonis in #5919
  • chore: Migrate from ESLint to oxlint by antonis in #5867
  • chore(deps): bump yaml to ^2.8.3 by antonis in #5921
  • chore(deps): bump activesupport to >= 7.2.3.1 by antonis in #5922
  • fix(ci): Update validate-pr action to remove draft enforcement by stephanie-anderson in #5923
  • chore(deps): bump actions/checkout from 4 to 6 by dependabot in #5916
  • chore(deps): bump getsentry/craft from 2.25.0 to 2.25.2 by dependabot in #5918
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.0 to 2.25.2 by dependabot in #5914
  • chore(deps): bump github/codeql-action from 4.34.1 to 4.35.1 by dependabot in #5917

Plus 12 more


🤖 This preview updates automatically when you update the PR.

@alwx alwx force-pushed the alwx/feat/app-loaded-api branch from b12e8e8 to d7e01e6 Compare March 31, 2026 13:19
@alwx alwx marked this pull request as ready for review March 31, 2026 13:37
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 4b535bf

alwx added 2 commits April 1, 2026 15:32
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().
@alwx alwx requested a review from antonis April 1, 2026 13:40
Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the two last comments from cursor and the Lint issue LGTM

Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connecting this to getsentry/sentry-cocoa#6886 for alignment as discussed on Slack

@linear-code
Copy link
Copy Markdown

linear-code bot commented Apr 2, 2026

@alwx alwx requested a review from antonis April 2, 2026 08:31
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.
@alwx alwx force-pushed the alwx/feat/app-loaded-api branch from bb7375b to 5b204e2 Compare April 2, 2026 08:36
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.
@alwx alwx added the ready-to-merge Triggers the full CI test suite label Apr 2, 2026
…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.
@alwx alwx requested a review from antonis April 2, 2026 08:56
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks valid but it might be an edge case not affecting the reported use case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

Android (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 399.38 ms 413.22 ms 13.84 ms
Size 43.75 MiB 48.08 MiB 4.33 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
4953e94+dirty 442.02 ms 456.52 ms 14.50 ms
df5d108+dirty 527.06 ms 603.58 ms 76.52 ms

App size

Revision Plain With Sentry Diff
4953e94+dirty 43.75 MiB 48.08 MiB 4.33 MiB
df5d108+dirty 43.75 MiB 48.08 MiB 4.33 MiB

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

iOS (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1223.76 ms 1219.91 ms -3.84 ms
Size 3.38 MiB 4.73 MiB 1.35 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
4953e94+dirty 1212.06 ms 1214.83 ms 2.77 ms
df5d108+dirty 1225.90 ms 1220.14 ms -5.76 ms

App size

Revision Plain With Sentry Diff
4953e94+dirty 3.38 MiB 4.73 MiB 1.35 MiB
df5d108+dirty 3.38 MiB 4.73 MiB 1.35 MiB

Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
It would be nice to also cover this edge case but I think it is not blocking give that the API is experimental

@alwx alwx merged commit a50b33d into main Apr 2, 2026
72 of 77 checks passed
@alwx alwx deleted the alwx/feat/app-loaded-api branch April 2, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants