Skip to content

fix(core): Measure callback-style native module calls until completion - #6561

Merged
alwx merged 11 commits into
mainfrom
alwx/fix/6542
Aug 11, 2026
Merged

fix(core): Measure callback-style native module calls until completion#6561
alwx merged 11 commits into
mainfrom
alwx/fix/6542

Conversation

@alwx

@alwx alwx commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

wrapTurboModule only recognised two completion signals: a plain return and a thenable return. Bridge methods that report completion through success/failure callbacks return undefined, so they were closed on return and recorded as sync calls with a near-zero duration — wrong turbo_module.* durations, and native.turbo_module slow-call breadcrumbs that could never fire.

Trailing completion callbacks are now wrapped, so the record closes when the callback fires. Details:

  • Argument convention. RN's genMethod fixes the shape: last argument is the success callback, second-to-last the failure one, and a non-function argument may never follow a function. 'promise'-typed methods never receive callbacks and are skipped — the thenable path already covers them.
  • Error attribution only where it's guaranteed. Failure callbacks count as errors on the Old Architecture bridge. New Architecture TurboModules take arbitrary callbacks with no such convention, so guessing there would corrupt errorCount; those calls close without an error flag.
  • kind stays 'async' — no change to TurboModuleCallKind, the aggregate keys, or the API report.
  • Crash attribution is unchanged. The frame is still popped synchronously. Holding it until a callback that may never fire would risk blaming the module for an unrelated later native crash — a regression on the feature the frame exists for. Only the timing record is deferred.
  • Bounded without timers. A cap on in-flight callback calls plus an amortised age sweep on insert. A call closed out that way is still counted with a zero duration; dropping it would hide the method from the aggregate entirely, which is worse than the ~0ms this fixes. The same clamp covers a callback firing implausibly late — on the New Architecture that is more likely a long-lived subscription handler than a completion callback.
  • A method that settles its callback inline stays kind: 'sync', so RN's 'sync' method type is not mislabelled.

The callback machinery lives in a new turboModuleCallbacks.ts because wrapTurboModule.ts would otherwise exceed oxlint's max-lines. No public API change — api-extractor reports the surface unchanged.

Fire-and-forget type: 'async' methods that take no callback still have no completion signal at all and remain ~0ms. Not addressed here.

💡 Motivation and Context

Fixes #6542, found by Cursor Bugbot on #6504. Callbacks are the dominant async shape on the Old Architecture bridge and are still used by some TurboModules, so this covered a large share of the calls the instrumentation reported on.

💚 How did you test it?

18 new tests — 16 unit (test/turbomodule/wrapTurboModule.test.ts) and 2 end-to-end through the integration (test/integrations/turboModuleContext.test.ts), covering: duration measured to the callback, inline settle staying sync, legacy failure-callback error attribution, no guessing on New Architecture, repeated callback invocations recorded once, receiver/argument/return pass-through, throwing callbacks, cap eviction, age sweep, the late-callback clamp, 'promise'-typed methods untouched, non-trailing function arguments ignored, slow-call breadcrumbs, and span credit going to the span open at call start.

Full local run: jest 1859 passed (+334 tools), oxlint 0/0, oxfmt clean, tsc -p tsconfig.build.json clean, api-extractor up to date, madge no cycles.

Not verified on a real device or simulator — the RN bridge method shape is modelled in tests, not executed.

📝 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

  • logger imported from @sentry/core in turbomodule/ is the Sentry Logs API, not the debug logger (that's debug). The 6 pre-existing logger.warn calls in wrapTurboModule.ts and 3 in wrapNativeModules.ts therefore capture log events from the wrap hot path — with a recursion flavour, since capturing a log goes through the wrapped RNSentry.captureEnvelope. New code here uses debug; the pre-existing calls are left for a separate fix so this diff stays scoped.
  • Never-settling promises leak a tracker frame unbounded today. The registry added here is the natural place to bound that.
  • ## Unreleased in CHANGELOG.md has two ### Fixes headings (pre-existing on main).
  • Docs, sentry-conventions attributes and the public-API lock are tracked in Turbo Modules: Documentation, migration guide, and public API #6168.

alwx added 2 commits August 4, 2026 15:47
`wrapTurboModule` only knew two completion signals: a plain return and a
thenable return. Bridge methods that report completion through
success/failure callbacks return `undefined`, so they were closed on
return and recorded as sync calls with a near-zero duration. RN's
`genMethod` confirms the shape — for `type: 'async'` the generated
wrapper returns nothing and hands the trailing callbacks to
`enqueueNativeCall`.

The same source fixes the argument convention: last argument is the
success callback, second-to-last the failure one, and a non-function may
never follow a function. Only the Old Architecture bridge guarantees
that, so failure callbacks are counted as errors there and left
unflagged on the New Architecture rather than guessing and corrupting
`errorCount`.

The crash-attribution frame is still popped synchronously. Holding it
until a callback that may never fire would risk blaming this module for
an unrelated later native crash — a regression on the very feature the
frame exists for. Only the timing record is deferred.

Records are bounded without timers: a cap plus an amortised age sweep on
insert. A call closed out that way is still counted, with a zero
duration — dropping it would hide the method from the aggregate
entirely, which is worse than the ~0ms this fixes. The same clamp covers
a callback firing implausibly late, which on the New Architecture may
well be a long-lived subscription handler rather than a completion
callback.

The callback machinery lives in its own module because
`wrapTurboModule.ts` would otherwise exceed oxlint's `max-lines`.

Fixes #6542
@github-actions

github-actions Bot commented Aug 4, 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(core): Measure callback-style native module calls until completion by alwx in #6561
  • fix(expo): Resolve config-plugins through the expo package by alwx in #6581
  • chore(deps): bump js-yaml to ^3.15.1 / ^4.3.1 by alwx in #6578
  • docs(core): Correct enableTurboModuleTracking docs and document TurboModule plumbing by alwx in #6582
  • docs: fix dead links in the README by luccasfraga in #6570
  • chore(deps): update Maestro to v2.8.0 by github-actions in #6551
  • chore(deps): update Android SDK to v8.52.0 by github-actions in #6566
  • chore(deps): bump the codeql-action group with 3 updates by dependabot in #6572
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.28.0 to 2.30.0 by dependabot in #6573
  • chore(deps): bump getsentry/craft from 2.28.0 to 2.30.0 by dependabot in #6575
  • chore(deps): bump dorny/paths-filter from 4.0.2 to 4.0.3 by dependabot in #6574
  • chore(deps): bump nanoid from 3.3.8 to 3.3.18 by dependabot in #6579
  • chore(deps): bump gradle/actions/setup-gradle from 6.2.0 to 6.3.0 by dependabot in #6576
  • chore(ci): remove Cirrus runners and use only Bitrise by itaybre in #6563
  • fix(ios): Re-land SentrySDK.internal migration, bump Cocoa SDK to 9.24.0 by alwx in #6541
  • ci: Replace curl-pipe-bash with action-setup-cli for Sentry CLI setup by oioki in #6567

🤖 This preview updates automatically when you update the PR.

Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
@alwx alwx self-assigned this Aug 5, 2026
…inline

A method that invokes its trailing callback inline and then returns a
thenable produced two aggregate rows for one invocation: the callback
closed the record as `sync`, and the promise handlers recorded it again
as `async`. The throw path already consulted `abandon()`'s return value;
the thenable path discarded it.

Whichever completion signal lands first now wins.

Reported by Warden on #6561.
@alwx
alwx marked this pull request as ready for review August 5, 2026 09:14

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

Reviewed by Cursor Bugbot for commit 8ef7342. Configure here.

Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
@lucas-zimerman lucas-zimerman added the ready-to-merge Triggers the full CI test suite label Aug 6, 2026
@lucas-zimerman

Copy link
Copy Markdown
Collaborator

All good! LGTM after dealing with the AI review

@sentry

sentry Bot commented Aug 6, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
Sentry RN io.sentry.reactnative.sample 8.22.0 (102) Release

⚙️ sentry-react-native Build Distribution Settings

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

iOS (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 3850.32 ms 1220.56 ms -2629.76 ms
Size 5.08 MiB 6.72 MiB 1.64 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
5fe1c6c+dirty 1220.79 ms 1217.63 ms -3.16 ms
0a147b2+dirty 3838.15 ms 1221.94 ms -2616.21 ms
41d6254+dirty 3845.71 ms 1224.51 ms -2621.20 ms
a636fa4+dirty 3852.48 ms 1236.10 ms -2616.38 ms
1a2e7e0+dirty 3842.49 ms 1220.04 ms -2622.45 ms
3d377b5+dirty 1218.48 ms 1219.51 ms 1.03 ms
4bb93c7+dirty 3845.98 ms 1218.02 ms -2627.96 ms
bbb8d67+dirty 3838.98 ms 1217.84 ms -2621.14 ms
ab203f9+dirty 3848.07 ms 1219.71 ms -2628.35 ms
7436d0f+dirty 3851.58 ms 1232.21 ms -2619.38 ms

App size

Revision Plain With Sentry Diff
5fe1c6c+dirty 3.38 MiB 4.77 MiB 1.39 MiB
0a147b2+dirty 4.98 MiB 6.51 MiB 1.53 MiB
41d6254+dirty 5.15 MiB 6.70 MiB 1.54 MiB
a636fa4+dirty 4.98 MiB 6.55 MiB 1.57 MiB
1a2e7e0+dirty 4.98 MiB 6.46 MiB 1.49 MiB
3d377b5+dirty 3.38 MiB 4.76 MiB 1.38 MiB
4bb93c7+dirty 5.08 MiB 6.68 MiB 1.60 MiB
bbb8d67+dirty 4.98 MiB 6.55 MiB 1.57 MiB
ab203f9+dirty 4.98 MiB 6.51 MiB 1.53 MiB
7436d0f+dirty 5.15 MiB 6.70 MiB 1.54 MiB

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

iOS (new) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 3842.71 ms 1219.91 ms -2622.80 ms
Size 5.08 MiB 6.72 MiB 1.64 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
7a89652+dirty 3827.11 ms 1211.49 ms -2615.62 ms
0a147b2+dirty 3845.11 ms 1230.81 ms -2614.30 ms
5b7e8a7+dirty 3855.46 ms 1234.47 ms -2620.99 ms
5fe1c6c+dirty 1201.36 ms 1209.15 ms 7.78 ms
580fb5c+dirty 3835.45 ms 1218.61 ms -2616.84 ms
94af3bd+dirty 3847.84 ms 1230.33 ms -2617.51 ms
f3215d3+dirty 3846.08 ms 1231.85 ms -2614.23 ms
a636fa4+dirty 3846.16 ms 1210.10 ms -2636.05 ms
5a21b51+dirty 3837.87 ms 1223.47 ms -2614.40 ms
4bb93c7+dirty 3869.22 ms 1230.76 ms -2638.47 ms

App size

Revision Plain With Sentry Diff
7a89652+dirty 5.15 MiB 6.70 MiB 1.55 MiB
0a147b2+dirty 4.98 MiB 6.51 MiB 1.53 MiB
5b7e8a7+dirty 5.15 MiB 6.70 MiB 1.54 MiB
5fe1c6c+dirty 3.38 MiB 4.77 MiB 1.39 MiB
580fb5c+dirty 4.98 MiB 6.46 MiB 1.48 MiB
94af3bd+dirty 5.15 MiB 6.69 MiB 1.53 MiB
f3215d3+dirty 5.15 MiB 6.67 MiB 1.52 MiB
a636fa4+dirty 4.98 MiB 6.55 MiB 1.57 MiB
5a21b51+dirty 5.15 MiB 6.67 MiB 1.51 MiB
4bb93c7+dirty 5.08 MiB 6.68 MiB 1.60 MiB

alwx added 3 commits August 10, 2026 14:41
`instrumentTrailingCallbacks` reads RN's `type` tag off the bridge method,
which can be a throwing accessor, and it ran before `originalFn.apply`
outside any `try` — a throw there would have blocked the native call with
the crash-attribution frame already pushed. `markReturned` ran after a
successful return, so a throw would have surfaced to the caller after the
real work had completed.

`instrumentTrailingCallbacks` is now a guarded shell that warns and returns
`undefined`. It also marks the call settled on failure, so callbacks it had
already swapped into `args` stay inert and the caller's own close path emits
exactly one record instead of zero or two. That is why `settled`/`returned`/
`pendingId` move out of closure variables into a shared state object: the
outer catch has to reach them.

`markReturned` guards the pending-call registration, whose eviction path is
the only part of the bookkeeping that calls out of the module. A failure
there costs only the in-flight cap for that call; the callback still closes
the record when it fires.

`abandon` is left unguarded on purpose — a local flag plus a `Map.delete`
cannot throw, and a catch there would be unreachable and untestable.
The 8.22.0 release was cut on main underneath this entry, so merging main
left it inside an already-published section.
@@ -98,33 +109,60 @@ export function wrapTurboModule<T extends object>(
logger.warn(`[TurboModuleTracker] notifyStart failed for ${name}.${key}: ${String(e)}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey, I know this is unrelated to this PR, but these logs on line 104 and 109 are being sent as user logs to sentry.io . Shouldn't we send it as debug warn?

@lucas-zimerman lucas-zimerman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

It is unrelated to this PR, we should address https://github.com/getsentry/sentry-react-native/pull/6561/changes#r3753179915 on a follow up PR.

@alwx
alwx merged commit 75735f9 into main Aug 11, 2026
86 of 89 checks passed
@alwx
alwx deleted the alwx/fix/6542 branch August 11, 2026 09:20
@alwx

alwx commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@lucas-zimerman here: #6585

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.

TurboModule instrumentation: callback-style bridge methods are recorded as ~0ms sync calls

2 participants