Skip to content

fix(profiling): Drive the native UI profiler from JS - #6916

Open
alwx wants to merge 6 commits into
mainfrom
alwx/bugfix/ui-profiling-start-stop
Open

alwx wants to merge 6 commits into
mainfrom
alwx/bugfix/ui-profiling-start-stop

Conversation

@alwx

@alwx alwx commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

This PR adds the startProfiler and stopProfiler bridge methods, and a default ProfilingIntegration that uses them to control the native UI profiler from JS. The native SDKs now always use the manual lifecycle. In trace mode, the integration runs the profiler while a sampled root span is active, and it calls stop one 60 second chunk after the last root span ends. In manual mode, it follows Sentry.profiler.startProfiler() and stopProfiler(), which are now exported.

Each transaction gets the profiler_id of the profile session that ran during its root span, and every span gets the native thread.id of the JS thread, so Sentry opens the profile on that thread. On iOS, sentry-cocoa has no public API for the profiler id, so iOS transactions are not linked to the profile yet.

💡 Motivation and Context

UI profiling from #5518 ran only for the app start profile. The native trace lifecycle needs native transactions, but React Native turns off native tracing. The manual lifecycle had no API that JS could call.

💚 How did you test it?

New Jest tests for the integration, the wrapper, and the default integrations. All JS tests, yarn lint, the build, and the API report pass. The updated Android RNSentryProfilingOptionsTest and the iOS RNSentryInitNativeSdkTests pass locally. I checked the change end to end on an Android 16 emulator with a test app and a Sentry project. Transactions get the profiler_id and the JS thread id, and the profile chunks cover the transactions on that thread.

📝 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.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.
  • No breaking changes.

🔮 Next steps

sentry-cocoa must expose the current profiler id, so that iOS transactions can link to the profile. In sentry-java (both profilers) and in sentry-cocoa, a start call that comes before a requested stop takes effect is ignored. The delayed stop in trace mode works around this for back-to-back spans, but a start in the window between the stop call and the end of the chunk still loses the rest of the profile. The native fixes are in getsentry/sentry-java#6291 and getsentry/sentry-cocoa#9401.

alwx added 2 commits October 20, 2026 12:55
UI profiling never ran after app start. React Native turns off native
tracing, so the native trace lifecycle never sees a sampled
transaction. Also, no API let JS start or stop the native profiler, so
the manual lifecycle could not work either.

Add the startProfiler and stopProfiler bridge methods. On Android, they
call Sentry.startProfiler() and Sentry.stopProfiler(), and
startProfiler returns the profiler id. On iOS, they call SentrySDK.
sentry-cocoa has no public API for the profiler id, so iOS returns nil.

Always set the manual lifecycle on the native side, because both
native SDKs refuse manual calls in trace mode. A new default
integration, ProfilingIntegration, now chooses when the profiler runs.
In trace mode, it runs while a sampled root span is active. In manual
mode, it follows Sentry.profiler.startProfiler() and stopProfiler(),
which are now exported. The integration waits for native init, stops
an app start profile that is no longer needed, and adds the profiler
id to transactions so that they link to the profile.
Replace the PR number placeholder in the CHANGELOG entry with #6916.
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

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).


  • fix(profiling): Drive the native UI profiler from JS by alwx in #6916
  • fix(core): Match Sentry package paths with Windows separators in Metro resolvers by alwx in #6915
  • chore(e2e): Update Expo sample to SDK 58 by antonis in #6914
  • chore(e2e): Bump E2E tests to React Native 0.88.0 by antonis in #6899
  • fix(ios): Find .xcode.env in the debug symbols phase under SwiftPM by alwx in #6911
  • chore(deps): update React Native to v0.88.0 by github-actions in #6903
  • chore(deps): bump getsentry/craft from 2.34.1 to 2.35.0 by dependabot in #6906
  • chore(deps): bump actions/create-github-app-token from 3.2.0 to 3.3.0 by dependabot in #6908
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.33.1 to 2.35.0 by dependabot in #6907
  • chore(sample): Bump React Native sample to 0.88.0 by antonis in #6910
  • chore(deps): bump actions/setup-java from 6.0.1 to 6.1.0 by dependabot in #6909
  • chore(deps): bump the codeql-action group with 3 updates by dependabot in #6905
  • chore(deps): update Sentry Android Gradle Plugin to v6.24.0 by github-actions in #6902
  • chore(deps): bump @appium/support from 7.0.6 to 7.2.8 by dependabot in #6897
  • ci(e2e): Remove app metrics job and performance-tests by antonis in #6896

🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor
Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request

Generated by 🚫 dangerJS against 4931e63

Comment thread packages/core/src/js/profiling/uiProfilingIntegration.ts Outdated
Comment thread packages/core/src/js/profiling/uiProfilingIntegration.ts Outdated
Warden found two problems with the profiler id. The integration kept
one id for all transactions, so transactions that ran after the
profile stopped still got the id of the stopped profile. Also, the id
arrived asynchronously and was dropped when the root span ended before
the native start call resolved.

Each start call now creates a session that holds the promise of the
native start call. Each sampled root span that is active while a
session runs is mapped to that session, and processEvent waits for the
promise before it adds the id. A transaction now gets only the id of a
profile that ran during its root span.

In trace mode, the integration now waits one 60 second chunk after the
last root span ends before it calls stop. The native SDKs ignore a
start call that comes before a requested stop takes effect at the end
of the chunk, so back-to-back root spans lost their profile after the
chunk ended.
Comment thread packages/core/src/js/profiling/uiProfilingIntegration.ts Outdated
Comment thread packages/core/src/js/profiling/uiProfilingIntegration.ts
Comment thread packages/core/src/js/integrations/default.ts
Sentry opens the profile of a span on the thread from the span's
thread.id. React Native spans had no thread.id, so the profile opened
on the main thread, and the JS work was in another, unnamed thread.

Add the getCurrentThreadId bridge method. It runs synchronously on the
JS thread and returns the thread id in the format of the native
profiler: the Linux thread id on Android and the Mach thread port on
iOS. The UI profiling integration reads it once and sets thread.id on
every span that does not have one.
Comment thread packages/core/test/profiling/uiProfilingIntegration.test.ts
@alwx
alwx marked this pull request as ready for review October 20, 2026 13:54
@alwx
alwx requested review from a team, antonis and lucas-zimerman as code owners October 20, 2026 13:54
…ampled roots

Warden found two more problems in the UI profiling integration.

In manual mode, a new session overwrote the session of root spans that
started under an earlier session. A transaction could then get the id
of a later profile. A root span now keeps the session that was running
when it started.

In trace mode with startOnAppStart, unsampled root spans were not
tracked, so their end never cleared the pending app start profile.
With tracesSampleRate: 0, the profiler then ran for the whole session.
The end of any root span now ends the app start profile.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4931e63. Configure here.

Comment thread packages/core/src/js/profiling/uiProfilingIntegration.ts
Comment on lines +94 to +95
if (jsThreadId && spanToJSON(span).data[SPAN_THREAD_ID] === undefined) {
span.setAttribute(SPAN_THREAD_ID, jsThreadId);

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.

onSpanStart crashes when spanToJSON().data is undefined

Use optional chaining (spanToJSON(span).data?.[SPAN_THREAD_ID]) so unsampled/NonRecording spans that omit data do not throw on every spanStart.

Evidence
  • onSpanStart runs the thread.id check for every span before the sampled/root early-return.
  • It indexes spanToJSON(span).data[SPAN_THREAD_ID] without ?., unlike addThreadInfoToSpan which uses data?..
  • When jsThreadId is set (native getCurrentThreadId()), unsampled spans whose spanToJSON() omits data throw TypeError on start.
  • Existing unsampled tests keep getCurrentThreadId mocked as null, so this path is untested.

Identified by Warden · code-review · PWQ-W6C

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.

Not changing this. spanToJSON() always returns data. SpanJSON.data is a required field, and the fallback branch for spans that are not SentrySpan returns data: {}. Unsampled root spans are SentrySpan instances, so getSpanJSON() also sets data. The ?. in addThreadInfoToSpan is not needed either.

Comment on lines +60 to +88
const stopNativeProfiler = (): void => {
clearStopTimeout();
session = undefined;
NATIVE.stopProfiler().then(undefined, () => {});
};

const syncNativeProfiler = (): void => {
if (!isNativeReady) {
return;
}

if (shouldRun()) {
clearStopTimeout();
if (!session) {
const newSession: ProfileSession = { profilerId: NATIVE.startProfiler() };
session = newSession;
activeRootSpanIds.forEach(spanId => {
// A root span that started under an earlier session keeps that profile.
if (!sessionByRootSpanId.has(spanId)) {
sessionByRootSpanId.set(spanId, newSession);
}
});
}
} else if (session && !stopTimeout) {
if (lifecycle === 'trace') {
stopTimeout = setTimeout(stopNativeProfiler, TRACE_STOP_DELAY_MS);
} else {
stopNativeProfiler();
}

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.

Immediate start after stop leaves a zombie JS session while native ignores start

stopNativeProfiler() clears session and requests stop, then a later start immediately sets session again; per the file comment native can ignore that start until the current 60s chunk ends, so JS will not call startProfiler again until shouldRun drops and clears session.

Evidence
  • Lines 15–18 document that native ignores start while a requested stop is still pending until the current 60s chunk ends.
  • stopNativeProfiler() (lines 60–64) sets session = undefined and fire-and-forgets NATIVE.stopProfiler() without a stopping/cooldown gate.
  • syncNativeProfiler() (lines 71–81) treats any falsy session as “not running” and immediately assigns session = { profilerId: NATIVE.startProfiler() } whenever shouldRun() is true.
  • Manual mode calls stopNativeProfiler() immediately on stop (lines 86–87), so stopProfiler() then startProfiler() hits this path with no delay; trace mode hits it on the first root span after the delayed stop fires.

Identified by Warden · find-bugs · 6SZ-X36

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.

This is the native bug that this PR works around in trace mode, not a JS state problem. The native SDKs ignore a start call that comes before a requested stop takes effect. getsentry/sentry-java#6291 and getsentry/sentry-cocoa#9401 fix that: a start call now cancels the pending stop, and the native profiler keeps running, which matches the JS session. Tracking a JS cooldown here would duplicate the native state and would go wrong as soon as the native fixes ship.

…app start profile

With startOnAppStart, the native SDK starts the profile before JS runs.
Root spans that started and ended before native init had no session,
so their transactions got no profiler id. Also, the afterInit handler
then saw nothing to run and stopped the app start profile at once,
without the delay that trace mode uses.

The integration now creates the app start session in setup. Root spans
that start before native init map to it, and its id resolves when
native init completes. After that, the normal logic decides when the
profiler stops. The session resolves without an id if the client
closes before native init, so no transaction waits forever.

@antonis antonis left a comment

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.

Overall the changes make sense. Let's merge the latest from main and run the full ci checks.

The native fixes are in getsentry/sentry-java#6291 and getsentry/sentry-cocoa#9401.

Do we need those to test end to end?

Let's also add an example link to the Sentry dashboard in the description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants