Skip to content

feat(core): Aggregate TurboModule call counts and latency per (module, method, kind)#6377

Open
alwx wants to merge 5 commits into
mainfrom
alwx/features/aggregated-stats
Open

feat(core): Aggregate TurboModule call counts and latency per (module, method, kind)#6377
alwx wants to merge 5 commits into
mainfrom
alwx/features/aggregated-stats

Conversation

@alwx

@alwx alwx commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • New feature

📜 Description

Adds per-(module, method, kind) aggregation for TurboModule invocations on top of the existing wrapTurboModule instrumentation. Aggregates flush at two points:

  1. On transaction finish — a synthetic turbo_modules.aggregate child 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.
  2. Lazy timer (default 30s) — emits an info-level event tagged event.kind=turbo_modules.aggregate so 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 turboModuleContextIntegration options: enableAggregateStats (default true), aggregateFlushIntervalMs (default 30000, 0 disables), 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

  • 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
  • No breaking changes

🔮 Next steps

@github-actions

github-actions Bot commented Jun 30, 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).


  • feat(core): Aggregate TurboModule call counts and latency per (module, method, kind) by alwx in #6377
  • refactor(ios): Migrate from PrivateSentrySDKOnly to SentrySDK.internal by alwx in #6380
  • docs: Improve PR template guidance by sentry-junior in #6447
  • fix(ios): Resolve getNewScreenTimeToDisplay Promise with number not array by antonis in #6438
  • fix(feedback): Guard show/hide against uninitialized _setVisibility by antonis in #6435
  • fix(tracing): Fix orphaned TTID/TTFD spans in the trace view by antonis in #6437
  • feat(tracing): Add reportFullyDisplayed() static API by antonis in #6419
  • feat(core): Track CaptureAppStartErrors adoption by antonis in #6429
  • feat(replay): Track mobile replay network capture adoption by antonis in #6428
  • feat(tracing): Track standalone app start adoption by antonis in #6427
  • feat(tracing): Track appLoaded and extendAppStart adoption by antonis in #6426
  • feat(tracing): Track wrapExpoRouterErrorBoundary adoption by antonis in #6425
  • feat(core): Track NavigationContainer adoption by antonis in #6424
  • feat(core): Track GlobalErrorBoundary adoption by antonis in #6421

🤖 This preview updates automatically when you update the PR.

alwx and others added 2 commits June 30, 2026 11:20
…, 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>
@alwx alwx force-pushed the alwx/features/aggregated-stats branch from 933fdb9 to 5702b46 Compare June 30, 2026 09:21
@github-actions

github-actions Bot commented Jun 30, 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
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 335716a

Comment thread packages/core/src/js/turbomodule/turboModuleAggregator.ts
@alwx alwx marked this pull request as ready for review July 13, 2026 12:43
@alwx alwx requested review from a team, antonis and lucas-zimerman as code owners July 13, 2026 12:43
@alwx alwx self-assigned this Jul 13, 2026
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
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.
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
`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>

@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 335716a. Configure here.

});
} finally {
setTimeout(endSuppressFirstTurboModuleRecordCallback, 0);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suppressed flush kills timer re-arm

Medium Severity

When flush self-noise from captureEvent is recorded under suppression, those entries stay in the aggregator. The next real TurboModule call then sees a non-empty map, so onFirstRecordAfterEmpty never runs and the lazy periodic timer is never re-armed. After the first suppressed flush, periodic aggregates stop until a transaction drain clears the leftover.

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 335716a. Configure here.

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.

Worth checking

@lucas-zimerman

lucas-zimerman commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

@alwx using some agents locally, it spotted the following, would you be able to verify on your side?

Trace this in order:

  1. Line 283: drainTurboModuleAggregate() — the map is now empty.
  2. Line 287: beginSuppressFirstTurboModuleRecordCallback() → bumps suppressFirstRecordCallbackDepth to 1.
  3. Line 289: client.captureEvent(...) — sending this event itself goes through RNSentry.captureEnvelope, which is a wrapped TurboModule (that's the whole point of this SDK — every native bridge call gets wrapped). So this call goes through the exact same recordTurboModuleCall you saw in Step 1.
  4. Back in recordTurboModuleCall: at line 131, wasEmpty = aggregates.size === 0 → true (we just drained in step 1). The write happens anyway (lines 135-162) — one entry for RNSentry.captureEnvelope gets added back into the map. At line 164, the callback check sees suppressFirstRecordCallbackDepth === 0 is false (it's 1), so onFirstRecordAfterEmpty() is correctly not called. Good — that part works as designed.
  5. Line 304: suppression ends (deferred one tick).

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.

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.

Aggregated per-module / per-method stats

2 participants