Skip to content

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

Open
alwx wants to merge 4 commits into
mainfrom
alwx/fix/6542
Open

fix(core): Measure callback-style native module calls until completion#6561
alwx wants to merge 4 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
  • ci: Replace secrets: inherit with explicit SENTRY_AUTH_TOKEN pass by oioki in #6562
  • chore(deps): update Wizard to v7.0.0 by github-actions in #6558
  • chore(deps): bump the codeql-action group with 3 updates by dependabot in #6553
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.26.14 to 2.28.0 by dependabot in #6554
  • chore(deps): bump fast-uri from 3.1.4 to 3.1.5 by dependabot in #6560
  • chore(deps): bump getsentry/craft from 2.26.14 to 2.28.0 by dependabot in #6556
  • chore(deps): bump ip-address from 10.2.0 to 10.4.0 by dependabot in #6557
  • chore(deps): bump undici from 6.27.0 to 6.28.0 by dependabot in #6559
  • chore(deps): bump actions/setup-java from 5.6.0 to 5.7.0 by dependabot in #6555
  • chore(deps): bump brace-expansion from 1.1.16 to 1.1.18 by dependabot in #6552
  • fix(expo): resolve plugin config when registered as bare '@sentry/react-native' by antonis in #6543
  • fix(ios): make the RNSentry SPEC CHECKSUM in Podfile.lock machine-independent by alwx in #6534
  • chore(deps): update Android SDK to v8.51.0 by github-actions in #6539
  • chore(deps): update Sentry Android Gradle Plugin to v6.17.0 by github-actions in #6544
  • feat(core): Extend TurboModule instrumentation to legacy NativeModules by alwx in #6504
  • feat(ios): Introduce enableMetricKit option by alwx in #6540

🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Aug 4, 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 8ef7342

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.

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Triggered by project rule: PR Review Guidelines for Cursor Bot

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

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.

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

1 participant