fix(core): Measure callback-style native module calls until completion - #6561
Conversation
`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
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
…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.
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.
Reviewed by Cursor Bugbot for commit 8ef7342. Configure here.
|
All good! LGTM after dealing with the AI review |
📲 Install BuildsAndroid
|
iOS (legacy) Performance metrics 🚀
|
| 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 |
iOS (new) Performance metrics 🚀
|
| 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 |
`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)}`); | |||
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
@lucas-zimerman here: #6585 |

📢 Type of change
📜 Description
wrapTurboModuleonly recognised two completion signals: a plain return and a thenable return. Bridge methods that report completion through success/failure callbacks returnundefined, so they were closed on return and recorded as sync calls with a near-zero duration — wrongturbo_module.*durations, andnative.turbo_moduleslow-call breadcrumbs that could never fire.Trailing completion callbacks are now wrapped, so the record closes when the callback fires. Details:
genMethodfixes 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.errorCount; those calls close without an error flag.kindstays'async'— no change toTurboModuleCallKind, the aggregate keys, or the API report.kind: 'sync', so RN's'sync'method type is not mislabelled.The callback machinery lives in a new
turboModuleCallbacks.tsbecausewrapTurboModule.tswould otherwise exceed oxlint'smax-lines. No public API change —api-extractorreports 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 stayingsync, 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:
jest1859 passed (+334 tools), oxlint 0/0, oxfmt clean,tsc -p tsconfig.build.jsonclean,api-extractorup to date,madgeno cycles.Not verified on a real device or simulator — the RN bridge method shape is modelled in tests, not executed.
📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
loggerimported from@sentry/coreinturbomodule/is the Sentry Logs API, not the debug logger (that'sdebug). The 6 pre-existinglogger.warncalls inwrapTurboModule.tsand 3 inwrapNativeModules.tstherefore capture log events from the wrap hot path — with a recursion flavour, since capturing a log goes through the wrappedRNSentry.captureEnvelope. New code here usesdebug; the pre-existing calls are left for a separate fix so this diff stays scoped.## UnreleasedinCHANGELOG.mdhas two### Fixesheadings (pre-existing onmain).sentry-conventionsattributes and the public-API lock are tracked in Turbo Modules: Documentation, migration guide, and public API #6168.