fix(core): Measure callback-style native module calls until completion - #6561
fix(core): Measure callback-style native module calls until completion#6561alwx wants to merge 4 commits into
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.
| // Must happen before the call: the wrapped callbacks have to be the ones | ||
| // handed to the native side. Mutates `args` in place, leaving arity and | ||
| // argument types untouched. | ||
| const callbackCall = instrumentTrailingCallbacks(args, originalFn, name, key, startedAtMs, recordId, arch); |
There was a problem hiding this comment.
Instrumentation can break user calls
Medium Severity
The new callback instrumentation is not isolated like the surrounding tracker calls. instrumentTrailingCallbacks and markReturned sit outside try/catch, so a throw can block the native call, surface after a successful return, or leave a pushed crash-attribution frame unpopped. This violates the PR review rule that Sentry instrumentation must never crash the host app.
Additional Locations (1)
Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 8ef7342. Configure here.


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