feat(core): Aggregate TurboModule call counts and latency per (module, method, kind)#6377
Conversation
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
Plus 2 more 🤖 This preview updates automatically when you update the PR. |
…, method, kind) Adds a small fixed-bucket histogram + counters per `(module, method, kind)` fed by the existing `wrapTurboModule` instrumentation. Aggregates flush on transaction finish (synthetic `turbo_modules.aggregate` child span + headline measurements on the root span) and on a lazy timer (info-level event for long-running sessions without transactions). Closes #6164. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop edge-case tests in favor of one happy-path check per surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
933fdb9 to
5702b46
Compare
|
The periodic flush's own `captureEvent` travels through the wrapped `RNSentry.captureEnvelope`, which records back into the aggregator and, because the map was just drained, re-armed the flush timer forever in otherwise-idle sessions. Suppress the empty→non-empty callback around the flush's `captureEvent`, deferring the release to the next macrotask so the async record fired from the transport's `.then()` also lands inside the suppression window.
`enableAggregateStats: false` skipped flush setup and processEvent but `wrapTurboModule` still recorded every call into the process-wide map, which had no drain path in that mode — so counters grew for the app's lifetime. Aggregator now exposes a master `setAggregateRecordingEnabled` switch that both no-ops future records and evicts existing entries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@alwx using some agents locally, it spotted the following, would you be able to verify on your side? Trace this in order:
Step 4 — the payoff: what happens to the next real call Now the map isn't empty anymore — it has that one leftover RNSentry.captureEnvelope entry sitting in it, untouched, forever (or until something else drains it). The next time a real TurboModule call happens (e.g. the user's own native module call), go back to Step 1, line 131: const wasEmpty = aggregates.size === 0; // FALSE — the leftover entry is still there wasEmpty is false, so the whole if (wasEmpty && onFirstRecordAfterEmpty && ...) block at line 164 is skipped — not because of suppression this time, but because the map genuinely isn't empty. The callback that arms the next 30-second timer never fires again. End state: periodic flush fired exactly once, quietly planted one leftover entry as its own exhaust, and that leftover permanently blocks the "empty → non-empty" trigger from ever detecting fresh work again — unless a transaction happens to fire processEvent and drain the map back to zero (which restarts the cycle, but only for one more round). The fix in one line: at aggregator line 164, also gate the write on suppression, not just the callback — i.e. bail out at the top of recordTurboModuleCall when suppressFirstRecordCallbackDepth > 0, so the periodic flush's own self-call never gets recorded as if it were real user traffic. |
The suppression window in `flushPeriodicAggregate` prevented the flush's own transport call from re-arming the timer, but left the recorded self-noise sitting in the aggregate map. The next real user call then saw `wasEmpty=false` and never fired `onFirstRecordAfterEmpty`, so the periodic timer stayed silent for the rest of the session (until a transaction drain cleared the leftover). Drain the aggregator when releasing the suppression scope. Trades a sub-millisecond window of possibly-real user records for a working lazy-timer re-arm on every subsequent call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous suppression + drain machinery worked around the flush's own `captureEvent → RNSentry.captureEnvelope` self-noise by drop-listing records around the send. That approach had two follow-on bugs: the blanket drain also discarded real user calls that raced with the flush window, and the async transport `.then()` sometimes fired outside the suppression scope entirely. Move the fix upstream: default `ignoreTurboModules` to `['RNSentry']`. Self-noise never enters the aggregator, so no suppression/drain gymnastics are needed. Users who want RNSentry stats can pass `ignoreTurboModules: []`. Also document the intentional data-loss trade-off when a transaction is dropped by `beforeSendTransaction` (bounded, self-healing on the next transaction or periodic flush). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 8d5c7ae. Configure here.
Nothing in the package reads it — `recordTurboModuleCall` checks the ignored set inline. Removing dead API surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lucas-zimerman
left a comment
There was a problem hiding this comment.
LGTM! Once ci test pass
📲 Install BuildsAndroid
|

📢 Type of change
📜 Description
Adds per-
(module, method, kind)aggregation for TurboModule invocations on top of the existingwrapTurboModuleinstrumentation. Aggregates flush at two points:turbo_modules.aggregatechild span on the event carries the per-method breakdown in span attributes; headline measurements (turbo_modules.call_count,.error_count,.total_ms,.top_module_ms) land on the root span for the standard Measurements panel.info-level event taggedevent.kind=turbo_modules.aggregateso idle sessions without transactions still produce a signal. Armed only on the first record after a drain, so silent sessions don't churn timers.New
turboModuleContextIntegrationoptions:enableAggregateStats(defaulttrue),aggregateFlushIntervalMs(default30000,0disables),ignoreTurboModules(excludes modules from counters; still wrapped for crash attribution).O(1) per call: counters + 6-bucket histogram (
<1ms, <5ms, <20ms, <100ms, <500ms, >=500ms). Failures inside the aggregator never break the wrapped TurboModule call.💡 Motivation and Context
Closes #6164. Per-call spans for every TurboModule invocation would explode span counts on hot async paths; aggregated counters are the right tradeoff.
💚 How did you test it?
📝 Checklist
sendDefaultPIIis enabled🔮 Next steps