From 9d4d836e6790e6e0ac69c4abba9799cb3f25fd98 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 30 Jun 2026 10:53:24 +0200 Subject: [PATCH 1/2] feat(core): Aggregate TurboModule call counts and latency per (module, 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 --- CHANGELOG.md | 17 + packages/core/etc/sentry-react-native.api.md | 8 +- packages/core/src/js/index.ts | 2 +- .../src/js/integrations/turboModuleContext.ts | 310 +++++++++++++++++- packages/core/src/js/turbomodule/index.ts | 13 +- .../js/turbomodule/turboModuleAggregator.ts | 218 ++++++++++++ .../src/js/turbomodule/turboModuleTracker.ts | 5 +- .../src/js/turbomodule/wrapTurboModule.ts | 30 +- .../integrations/turboModuleContext.test.ts | 215 +++++++++++- .../turbomodule/turboModuleAggregator.test.ts | 157 +++++++++ .../test/turbomodule/wrapTurboModule.test.ts | 49 +++ 11 files changed, 1010 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/js/turbomodule/turboModuleAggregator.ts create mode 100644 packages/core/test/turbomodule/turboModuleAggregator.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc644c434..54e50951d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ ### Features +- Aggregate TurboModule call counts + latency per `(module, method, kind)` and flush them on transaction finish and on a lazy periodic timer ([#6377](https://github.com/getsentry/sentry-react-native/pull/6377)) + + Counters land on the finishing transaction as a synthetic `turbo_modules.aggregate` child span (per-call breakdown in span attributes) plus headline measurements on the root span (`turbo_modules.call_count`, `turbo_modules.error_count`, `turbo_modules.total_ms`, `turbo_modules.top_module_ms`). Long-running sessions without transactions emit a periodic info-level event (default every 30s, only when there's data). + + ```ts + Sentry.init({ + integrations: [ + Sentry.turboModuleContextIntegration({ + // optional knobs (defaults shown): + enableAggregateStats: true, + aggregateFlushIntervalMs: 30_000, + ignoreTurboModules: ['RNSentry'], + }), + ], + }); + ``` + - Expose top-level `Sentry.setAttribute` and `Sentry.setAttributes` APIs ([#6354](https://github.com/getsentry/sentry-react-native/pull/6354)). - Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) - Use the runtime's native `btoa` for envelope base64 encoding when available, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if `btoa` is missing ([#6351](https://github.com/getsentry/sentry-react-native/pull/6351)). diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index bcf86cf2e7..56165ec785 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -842,17 +842,23 @@ export { TransactionEvent } // @public export interface TurboModuleCall { callId: number; - kind: 'sync' | 'async'; + kind: TurboModuleCallKind; method: string; name: string; startedAtMs: number; } +// @public +export type TurboModuleCallKind = 'sync' | 'async'; + // @public export const turboModuleContextIntegration: (options?: TurboModuleContextOptions) => Integration; // @public (undocumented) export interface TurboModuleContextOptions { + aggregateFlushIntervalMs?: number; + enableAggregateStats?: boolean; + ignoreTurboModules?: ReadonlyArray; modules?: Array<{ name: string; module: object | null | undefined; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 3b609bde18..0ee9f6a9cf 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -168,4 +168,4 @@ export { pushTurboModuleCall, wrapTurboModule, } from './turbomodule'; -export type { TurboModuleCall } from './turbomodule'; +export type { TurboModuleCall, TurboModuleCallKind } from './turbomodule'; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index a577da3685..ac987209c0 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -1,19 +1,85 @@ -import type { Integration } from '@sentry/core'; +import type { Client, Event, Integration, TransactionEvent } from '@sentry/core'; -import { wrapTurboModule } from '../turbomodule'; +import { debug } from '@sentry/core'; + +import { createSpanJSON } from '../tracing/utils'; +import { + drainTurboModuleAggregate, + HISTOGRAM_BUCKET_LABELS, + hasTurboModuleAggregateData, + setIgnoredTurboModules, + setOnFirstTurboModuleRecord, + type TurboModuleAggregate, + wrapTurboModule, +} from '../turbomodule'; import { getRNSentryModule } from '../wrapper'; export const INTEGRATION_NAME = 'TurboModuleContext'; +/** Op for the synthetic child span that carries the aggregate breakdown. */ +export const TURBO_MODULES_AGGREGATE_OP = 'turbo_modules.aggregate'; + +/** Origin string set on the aggregate span so it shows up as auto-instrumented. */ +export const TURBO_MODULES_AGGREGATE_ORIGIN = 'auto.tracing.turbo_modules'; + +/** Default flush cadence for the periodic timer, in milliseconds. */ +export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30_000; + +/** + * Maximum number of `(module, method, kind)` triplets serialised as span + * attributes on a single flush. Beyond this, the long tail is dropped from + * the attribute payload — the headline measurements still reflect the totals. + */ +const MAX_AGGREGATE_ATTRIBUTE_ROWS = 64; + export interface TurboModuleContextOptions { /** * Additional TurboModules to track. Each entry's methods will be wrapped so * that any native crash happening inside a method call gets `contexts.turbo_module` - * + `turbo_module.name` / `turbo_module.method` attached to the crash report. + * + `turbo_module.name` / `turbo_module.method` attached to the crash report, + * and so the calls are recorded into the aggregator (subject to + * `ignoreTurboModules`). * * The built-in `RNSentry` TurboModule is always tracked. */ modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; + + /** + * Per-(module, method, kind) call-count / latency aggregation. When enabled, + * each wrapped TurboModule invocation contributes to a small fixed set of + * counters that flush: + * - on every transaction finish, as a synthetic `turbo_modules.aggregate` + * child span (per-call data in span attributes) plus headline + * measurements on the root span; + * - on a periodic timer (see `aggregateFlushIntervalMs`) so + * long-running sessions without transactions still emit a signal. + * + * Default: `true`. + * + * See https://github.com/getsentry/sentry-react-native/issues/6164. + */ + enableAggregateStats?: boolean; + + /** + * Interval in milliseconds for the periodic aggregate flush. Only used when + * `enableAggregateStats` is enabled. The periodic flush emits a custom + * Sentry event so the data survives sessions that never produce a transaction. + * + * Default: 30000 (30s). Set to `0` to disable the periodic timer (data is + * still flushed on transaction finish). + */ + aggregateFlushIntervalMs?: number; + + /** + * TurboModules whose calls should NOT be counted in the aggregate. Users + * may e.g. want to exclude `RNSentry` itself to keep the signal clean of + * the SDK's own internal calls. + * + * Note: this does NOT disable wrapping — crashes during those calls still + * get attributed via `contexts.turbo_module`. It only opts the module out + * of the per-(module, method, kind) counters. + */ + ignoreTurboModules?: ReadonlyArray; } // Methods on RNSentry that must NOT be tracked: @@ -46,14 +112,22 @@ const RNSENTRY_SKIP = [ * that native crashes can be attributed to the high-level RN module + method * (e.g. `RNSentry.captureEnvelope`) on top of the native stack trace. * - * The active call is mirrored as `contexts.turbo_module` and the - * `turbo_module.name` / `turbo_module.method` tags, both of which are already - * synced to the native SDKs by the existing scope-sync hooks and therefore end - * up in crash reports captured by sentry-cocoa / sentry-java. + * Additionally aggregates per-(module, method, kind) call-count / latency + * counters and flushes them on transaction finish (as a synthetic + * `turbo_modules.aggregate` child span with headline measurements on the root + * span) and on a periodic timer (as a custom Sentry event) — see + * https://github.com/getsentry/sentry-react-native/issues/6164. * - * See https://github.com/getsentry/sentry-react-native/issues/6163. + * See https://github.com/getsentry/sentry-react-native/issues/6163 for the + * crash-attribution side of this integration. */ export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { + const enableAggregate = options.enableAggregateStats !== false; + const flushIntervalMs = options.aggregateFlushIntervalMs ?? DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS; + + let pendingFlushHandle: ReturnType | undefined; + let closed = false; + return { name: INTEGRATION_NAME, setupOnce() { @@ -66,6 +140,226 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions for (const entry of options.modules ?? []) { wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods }); } + + if (enableAggregate) { + setIgnoredTurboModules(options.ignoreTurboModules); + } + }, + setup(client: Client): void { + if (!enableAggregate) { + return; + } + + // Flush on transaction finish is handled in `processEvent` below — by + // the time `processEvent` runs the root span has already been built up + // and we get a chance to mutate the serialised transaction directly, + // avoiding a race with the root span's `end()`. + + // Periodic flush keeps the signal alive in sessions that never produce + // a transaction (e.g. background JS work, long idle sessions with no + // navigation). We arm a one-shot timer lazily — only when the + // aggregator transitions from empty to non-empty — so idle sessions + // don't churn a recurring timer. The next record after a flush + // re-arms it. + if (flushIntervalMs > 0) { + setOnFirstTurboModuleRecord(() => { + if (closed || pendingFlushHandle !== undefined) { + return; + } + pendingFlushHandle = setTimeout(() => { + pendingFlushHandle = undefined; + flushPeriodicAggregate(client); + }, flushIntervalMs); + }); + } + + client.on?.('close', () => { + closed = true; + setOnFirstTurboModuleRecord(undefined); + if (pendingFlushHandle !== undefined) { + clearTimeout(pendingFlushHandle); + pendingFlushHandle = undefined; + } + }); + }, + processEvent(event: Event): Event { + if (!enableAggregate || event.type !== 'transaction') { + return event; + } + if (!hasTurboModuleAggregateData()) { + return event; + } + attachAggregateToTransactionEvent(event as TransactionEvent); + return event; }, }; }; + +/** + * Mutates a transaction event in place to add the aggregate breakdown as a + * synthetic child span plus a few headline measurements on the root span. + */ +function attachAggregateToTransactionEvent(event: TransactionEvent): void { + const trace = event.contexts?.trace; + if (!trace?.trace_id || !trace.span_id) { + return; + } + const startTs = event.start_timestamp; + const endTs = event.timestamp; + if (typeof startTs !== 'number' || typeof endTs !== 'number') { + return; + } + + const snapshot = drainTurboModuleAggregate(); + if (snapshot.length === 0) { + return; + } + + const totals = summarise(snapshot); + const topByTotalMs = [...snapshot].sort((a, b) => b.totalDurationMs - a.totalDurationMs); + + const aggregateSpan = createSpanJSON({ + op: TURBO_MODULES_AGGREGATE_OP, + description: 'TurboModule call aggregate', + start_timestamp: startTs, + timestamp: endTs, + trace_id: trace.trace_id, + parent_span_id: trace.span_id, + origin: TURBO_MODULES_AGGREGATE_ORIGIN, + data: { + 'turbo_modules.total_call_count': totals.callCount, + 'turbo_modules.total_error_count': totals.errorCount, + 'turbo_modules.total_duration_ms': roundMs(totals.totalDurationMs), + 'turbo_modules.unique_methods': snapshot.length, + ...serialiseRows(topByTotalMs.slice(0, MAX_AGGREGATE_ATTRIBUTE_ROWS)), + }, + }); + + event.spans = event.spans ?? []; + event.spans.push(aggregateSpan); + + event.measurements = event.measurements ?? {}; + event.measurements['turbo_modules.call_count'] = { value: totals.callCount, unit: 'none' }; + event.measurements['turbo_modules.error_count'] = { value: totals.errorCount, unit: 'none' }; + event.measurements['turbo_modules.total_ms'] = { value: roundMs(totals.totalDurationMs), unit: 'millisecond' }; + + const top = topByTotalMs[0]; + if (top) { + event.measurements['turbo_modules.top_module_ms'] = { + value: roundMs(top.totalDurationMs), + unit: 'millisecond', + }; + } + + if (snapshot.length > MAX_AGGREGATE_ATTRIBUTE_ROWS) { + debug.log( + `[TurboModuleContext] Aggregate has ${snapshot.length} rows, truncated to top ${MAX_AGGREGATE_ATTRIBUTE_ROWS} ` + + `by total_ms on the aggregate span. Headline measurements still reflect the full totals.`, + ); + } +} + +/** + * Emits the current aggregate as a custom Sentry event so long-running + * sessions without a transaction still produce a signal. No-op when there's + * nothing to flush. + */ +function flushPeriodicAggregate(client: Client): void { + if (!hasTurboModuleAggregateData()) { + return; + } + const snapshot = drainTurboModuleAggregate(); + const totals = summarise(snapshot); + const topByTotalMs = [...snapshot].sort((a, b) => b.totalDurationMs - a.totalDurationMs); + + client.captureEvent?.({ + message: 'TurboModule aggregate (periodic)', + level: 'info', + tags: { + 'event.kind': 'turbo_modules.aggregate', + }, + extra: { + total_call_count: totals.callCount, + total_error_count: totals.errorCount, + total_duration_ms: roundMs(totals.totalDurationMs), + unique_methods: snapshot.length, + modules: topByTotalMs.slice(0, MAX_AGGREGATE_ATTRIBUTE_ROWS).map(serialiseRowAsObject), + }, + }); +} + +function summarise(snapshot: ReadonlyArray): { + callCount: number; + errorCount: number; + totalDurationMs: number; +} { + let callCount = 0; + let errorCount = 0; + let totalDurationMs = 0; + for (const row of snapshot) { + callCount += row.callCount; + errorCount += row.errorCount; + totalDurationMs += row.totalDurationMs; + } + return { callCount, errorCount, totalDurationMs }; +} + +/** + * Serialises an aggregate row into a flat set of span-attribute keys, prefixed + * with the `(name.method.kind)` triplet. Span attributes are flat key→scalar + * pairs so nested objects aren't an option here. + */ +function serialiseRows(rows: ReadonlyArray): Record { + const out: Record = {}; + for (const row of rows) { + const prefix = `turbo_modules.${row.name}.${row.method}.${row.kind}`; + out[`${prefix}.count`] = row.callCount; + out[`${prefix}.error_count`] = row.errorCount; + out[`${prefix}.total_ms`] = roundMs(row.totalDurationMs); + out[`${prefix}.max_ms`] = roundMs(row.maxDurationMs); + for (let i = 0; i < row.buckets.length; i++) { + const label = HISTOGRAM_BUCKET_LABELS[i]; + const count = row.buckets[i]; + if (label !== undefined && count !== undefined) { + out[`${prefix}.${label}`] = count; + } + } + } + return out; +} + +function serialiseRowAsObject(row: TurboModuleAggregate): { + name: string; + method: string; + kind: string; + call_count: number; + error_count: number; + total_ms: number; + max_ms: number; + histogram: Record; +} { + const histogram: Record = {}; + for (let i = 0; i < row.buckets.length; i++) { + const label = HISTOGRAM_BUCKET_LABELS[i]; + const count = row.buckets[i]; + if (label !== undefined && count !== undefined) { + histogram[label] = count; + } + } + return { + name: row.name, + method: row.method, + kind: row.kind, + call_count: row.callCount, + error_count: row.errorCount, + total_ms: roundMs(row.totalDurationMs), + max_ms: roundMs(row.maxDurationMs), + histogram, + }; +} + +function roundMs(value: number): number { + // Two-decimal precision is more than enough for human-readable totals + // and keeps the JSON payload terse. + return Math.round(value * 100) / 100; +} diff --git a/packages/core/src/js/turbomodule/index.ts b/packages/core/src/js/turbomodule/index.ts index f75620a1b2..91b8c50206 100644 --- a/packages/core/src/js/turbomodule/index.ts +++ b/packages/core/src/js/turbomodule/index.ts @@ -4,5 +4,16 @@ export { popTurboModuleCall, pushTurboModuleCall, } from './turboModuleTracker'; -export type { TurboModuleCall } from './turboModuleTracker'; +export type { TurboModuleCall, TurboModuleCallKind } from './turboModuleTracker'; +export { + drainTurboModuleAggregate, + HISTOGRAM_BUCKET_LABELS, + HISTOGRAM_BUCKETS_MS, + hasTurboModuleAggregateData, + isTurboModuleIgnored, + recordTurboModuleCall, + setIgnoredTurboModules, + setOnFirstTurboModuleRecord, +} from './turboModuleAggregator'; +export type { TurboModuleAggregate } from './turboModuleAggregator'; export { wrapTurboModule } from './wrapTurboModule'; diff --git a/packages/core/src/js/turbomodule/turboModuleAggregator.ts b/packages/core/src/js/turbomodule/turboModuleAggregator.ts new file mode 100644 index 0000000000..ed953a0876 --- /dev/null +++ b/packages/core/src/js/turbomodule/turboModuleAggregator.ts @@ -0,0 +1,218 @@ +/** + * Per-(module, method, kind) aggregation of TurboModule invocations. + * + * The wrap layer in `wrapTurboModule` already measures each call's duration + * and outcome. Sending one span per call would explode span counts on hot + * async paths (every `RNSentry.captureEnvelope`, every JSI lookup, …) so + * instead we keep O(1) per-key counters + a fixed-bucket histogram and flush + * the aggregate at coarse-grained points (transaction finish, periodic timer). + * + * See https://github.com/getsentry/sentry-react-native/issues/6164. + */ + +import type { TurboModuleCallKind } from './turboModuleTracker'; + +/** Upper-exclusive bucket boundaries in milliseconds, matching the issue. */ +export const HISTOGRAM_BUCKETS_MS: readonly number[] = [1, 5, 20, 100, 500]; + +/** Suffixes used when serialising bucket counts (e.g. as span attributes). */ +export const HISTOGRAM_BUCKET_LABELS: readonly string[] = [ + 'lt_1ms', + 'lt_5ms', + 'lt_20ms', + 'lt_100ms', + 'lt_500ms', + 'gte_500ms', +]; + +/** + * Aggregate counters for a single `(module, method, kind)` triplet. + */ +export interface TurboModuleAggregate { + /** TurboModule name, e.g. `RNSentry`. */ + name: string; + /** Method name, e.g. `captureEnvelope`. */ + method: string; + /** Whether the invocation was `sync` (blocking) or `async` (returns a Promise). */ + kind: TurboModuleCallKind; + /** Number of calls recorded since the last drain. */ + callCount: number; + /** Number of calls that threw / rejected since the last drain. */ + errorCount: number; + /** Sum of call durations in milliseconds since the last drain. */ + totalDurationMs: number; + /** Largest single-call duration in milliseconds since the last drain. */ + maxDurationMs: number; + /** + * Per-bucket call counts, aligned with {@link HISTOGRAM_BUCKETS_MS}. The + * final entry is the overflow bucket (`>=500ms`). + */ + buckets: number[]; +} + +interface MutableAggregate extends TurboModuleAggregate { + buckets: number[]; +} + +const aggregates = new Map(); +const ignoredModules = new Set(); +let onFirstRecordAfterEmpty: (() => void) | undefined; + +function makeKey(name: string, method: string, kind: TurboModuleCallKind): string { + return `${name}|${method}|${kind}`; +} + +function bucketIndexForDuration(durationMs: number): number { + for (let i = 0; i < HISTOGRAM_BUCKETS_MS.length; i++) { + // `i` is bounded by `.length`, so the read is in range — `?? Infinity` + // is a noop at runtime but satisfies `noUncheckedIndexedAccess`. + const boundary = HISTOGRAM_BUCKETS_MS[i] ?? Infinity; + if (durationMs < boundary) { + return i; + } + } + return HISTOGRAM_BUCKETS_MS.length; +} + +/** + * Replaces the set of TurboModule names whose calls should NOT be aggregated. + * + * Per the issue, users may want to opt-out specific modules (e.g. `RNSentry` + * itself, to keep the signal clean of SDK overhead). An empty list (default) + * means every wrapped module is aggregated. + */ +export function setIgnoredTurboModules(names: ReadonlyArray | undefined): void { + ignoredModules.clear(); + if (!names) { + return; + } + for (const name of names) { + ignoredModules.add(name); + } +} + +/** + * Returns whether the given TurboModule is currently opted out of aggregation. + */ +export function isTurboModuleIgnored(name: string): boolean { + return ignoredModules.has(name); +} + +/** + * Records a single TurboModule method invocation into the aggregate. + * + * Must be O(1): called on every wrapped method invocation, including hot + * async paths. Negative durations (a clock skew artefact between push/pop) + * are clamped to zero so they still increment counters but don't poison + * totals or buckets. + */ +export function recordTurboModuleCall(args: { + name: string; + method: string; + kind: TurboModuleCallKind; + durationMs: number; + errored: boolean; +}): void { + if (ignoredModules.has(args.name)) { + return; + } + + const wasEmpty = aggregates.size === 0; + const duration = args.durationMs > 0 ? args.durationMs : 0; + const key = makeKey(args.name, args.method, args.kind); + + let entry = aggregates.get(key); + if (!entry) { + entry = { + name: args.name, + method: args.method, + kind: args.kind, + callCount: 0, + errorCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + buckets: new Array(HISTOGRAM_BUCKETS_MS.length + 1).fill(0) as number[], + }; + aggregates.set(key, entry); + } + + entry.callCount += 1; + if (args.errored) { + entry.errorCount += 1; + } + entry.totalDurationMs += duration; + if (duration > entry.maxDurationMs) { + entry.maxDurationMs = duration; + } + + const bucket = bucketIndexForDuration(duration); + // Bucket index is bounded by `bucketIndexForDuration`; `?? 0` here only + // exists to satisfy `noUncheckedIndexedAccess`. + entry.buckets[bucket] = (entry.buckets[bucket] ?? 0) + 1; + + if (wasEmpty && onFirstRecordAfterEmpty) { + // Don't let a misbehaving observer corrupt the aggregate. + try { + onFirstRecordAfterEmpty(); + } catch { + // intentionally swallowed + } + } +} + +/** + * Registers a callback fired exactly once when the aggregator transitions + * from empty to non-empty — i.e. when the first record after a drain (or + * after init) lands. The integration uses this to lazily schedule a periodic + * flush only when there's work to do, so idle sessions don't churn timers. + * + * Pass `undefined` to unregister. + */ +export function setOnFirstTurboModuleRecord(cb: (() => void) | undefined): void { + onFirstRecordAfterEmpty = cb; +} + +/** + * Drains and returns the current aggregate, clearing the internal state. + * + * The returned array is a shallow copy: callers may freely mutate it (e.g. + * to slice top-N) without affecting the next interval. `buckets` arrays on + * each entry are also new instances. + */ +export function drainTurboModuleAggregate(): TurboModuleAggregate[] { + if (aggregates.size === 0) { + return []; + } + const out: TurboModuleAggregate[] = []; + for (const entry of aggregates.values()) { + out.push({ + name: entry.name, + method: entry.method, + kind: entry.kind, + callCount: entry.callCount, + errorCount: entry.errorCount, + totalDurationMs: entry.totalDurationMs, + maxDurationMs: entry.maxDurationMs, + buckets: entry.buckets.slice(), + }); + } + aggregates.clear(); + return out; +} + +/** + * Returns whether the aggregator has anything to flush right now. Useful for + * the periodic timer to skip a no-op send. + */ +export function hasTurboModuleAggregateData(): boolean { + return aggregates.size > 0; +} + +/** + * Resets the aggregator. Tests only. + */ +export function _resetTurboModuleAggregator(): void { + aggregates.clear(); + ignoredModules.clear(); + onFirstRecordAfterEmpty = undefined; +} diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index 25941b69cc..9d749031aa 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -2,6 +2,9 @@ import type { Scope } from '@sentry/core'; import { getIsolationScope } from '@sentry/core'; +/** Whether a TurboModule invocation is `sync` (blocking) or `async` (returns a Promise). */ +export type TurboModuleCallKind = 'sync' | 'async'; + /** * Describes a single TurboModule method invocation currently in flight. */ @@ -11,7 +14,7 @@ export interface TurboModuleCall { /** Method name, e.g. `captureEnvelope`. */ method: string; /** Whether the invocation is `sync` (blocking) or `async` (returns a Promise). */ - kind: 'sync' | 'async'; + kind: TurboModuleCallKind; /** `Date.now()` at the moment the call started. */ startedAtMs: number; /** Monotonically increasing id, used as the JS-side `call_id` cross-reference. */ diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 48741e00cb..26f5e8f7cf 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -1,5 +1,8 @@ import { logger } from '@sentry/core'; +import type { TurboModuleCallKind } from './turboModuleTracker'; + +import { recordTurboModuleCall } from './turboModuleAggregator'; import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; /** @@ -81,6 +84,7 @@ export function wrapTurboModule( // We don't know yet whether `original` is sync or async — start optimistic // as sync, relabel to 'async' if the result turns out to be thenable. let callId: number | undefined; + const startedAtMs = Date.now(); try { callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); } catch (e) { @@ -92,6 +96,7 @@ export function wrapTurboModule( result = originalFn.apply(this, args); } catch (e) { safePop(callId, name, key); + safeRecord(name, key, 'sync', startedAtMs, true); throw e; } @@ -100,16 +105,19 @@ export function wrapTurboModule( return (result as Promise).then( value => { safePop(callId, name, key); + safeRecord(name, key, 'async', startedAtMs, false); return value; }, err => { safePop(callId, name, key); + safeRecord(name, key, 'async', startedAtMs, true); throw err; }, ); } safePop(callId, name, key); + safeRecord(name, key, 'sync', startedAtMs, false); return result; }; @@ -174,7 +182,7 @@ function safePop(callId: number | undefined, name: string, method: string): void } } -function safeRelabel(callId: number | undefined, kind: 'sync' | 'async', name: string, method: string): void { +function safeRelabel(callId: number | undefined, kind: TurboModuleCallKind, name: string, method: string): void { if (callId === undefined) { return; } @@ -185,6 +193,26 @@ function safeRelabel(callId: number | undefined, kind: 'sync' | 'async', name: s } } +function safeRecord( + name: string, + method: string, + kind: TurboModuleCallKind, + startedAtMs: number, + errored: boolean, +): void { + try { + recordTurboModuleCall({ + name, + method, + kind, + durationMs: Date.now() - startedAtMs, + errored, + }); + } catch (e) { + logger.warn(`[TurboModuleTracker] record failed for ${name}.${method}: ${String(e)}`); + } +} + function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index d0b6207159..de53a1e275 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -1,20 +1,52 @@ +import type { Client, TransactionEvent } from '@sentry/core'; + import { Scope } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { turboModuleContextIntegration } from '../../src/js/integrations/turboModuleContext'; +import { + DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS, + turboModuleContextIntegration, + TURBO_MODULES_AGGREGATE_OP, +} from '../../src/js/integrations/turboModuleContext'; import * as turboModule from '../../src/js/turbomodule'; +import { _resetTurboModuleAggregator, recordTurboModuleCall } from '../../src/js/turbomodule/turboModuleAggregator'; import * as wrapper from '../../src/js/wrapper'; +function makeTransactionEvent(overrides: Partial = {}): TransactionEvent { + return { + type: 'transaction', + start_timestamp: 1_000, + timestamp: 2_000, + contexts: { + trace: { + trace_id: 'a'.repeat(32), + span_id: 'b'.repeat(16), + }, + }, + ...overrides, + } as TransactionEvent; +} + +function makeMockClient(): Client & { on: jest.Mock; captureEvent: jest.Mock } { + return { + on: jest.fn(), + captureEvent: jest.fn(), + } as unknown as Client & { on: jest.Mock; captureEvent: jest.Mock }; +} + describe('turboModuleContextIntegration', () => { let scope: Scope; beforeEach(() => { scope = new Scope(); jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); + _resetTurboModuleAggregator(); }); afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); + _resetTurboModuleAggregator(); }); it('wraps the live RNSentry TurboModule on setup', () => { @@ -106,4 +138,185 @@ describe('turboModuleContextIntegration', () => { expect(() => turboModuleContextIntegration().setupOnce!()).not.toThrow(); }); + + describe('aggregate stats — processEvent flush', () => { + beforeEach(() => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + }); + + it('attaches a turbo_modules.aggregate child span + headline measurements on transaction events', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + integration.setup?.(makeMockClient()); + + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 12, + errored: false, + }); + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 8, + errored: true, + }); + + const event = makeTransactionEvent(); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + + expect(out.spans).toHaveLength(1); + expect(out.spans?.[0]).toMatchObject({ + op: TURBO_MODULES_AGGREGATE_OP, + trace_id: 'a'.repeat(32), + parent_span_id: 'b'.repeat(16), + }); + expect(out.spans?.[0]?.data).toMatchObject({ + 'turbo_modules.total_call_count': 2, + 'turbo_modules.total_error_count': 1, + 'turbo_modules.total_duration_ms': 20, + 'turbo_modules.RNSentry.captureEnvelope.async.count': 2, + 'turbo_modules.RNSentry.captureEnvelope.async.error_count': 1, + 'turbo_modules.RNSentry.captureEnvelope.async.total_ms': 20, + }); + expect(out.measurements).toMatchObject({ + 'turbo_modules.call_count': { value: 2, unit: 'none' }, + 'turbo_modules.error_count': { value: 1, unit: 'none' }, + 'turbo_modules.total_ms': { value: 20, unit: 'millisecond' }, + 'turbo_modules.top_module_ms': { value: 20, unit: 'millisecond' }, + }); + }); + + it('clears the aggregator after a successful flush', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + integration.setup?.(makeMockClient()); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + + const firstEvent = makeTransactionEvent(); + integration.processEvent?.(firstEvent, {}, makeMockClient()); + expect(firstEvent.spans).toHaveLength(1); + + // A subsequent transaction with no calls in between gets nothing. + const secondEvent = makeTransactionEvent(); + const secondOut = integration.processEvent?.(secondEvent, {}, makeMockClient()) as TransactionEvent; + expect(secondOut.spans ?? []).toHaveLength(0); + expect(secondOut.measurements ?? {}).toEqual({}); + }); + + it('does not touch non-transaction events', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + integration.setup?.(makeMockClient()); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + + const errorEvent = { type: undefined, message: 'oops' } as unknown as TransactionEvent; + const out = integration.processEvent?.(errorEvent, {}, makeMockClient()); + expect((out as TransactionEvent).spans).toBeUndefined(); + expect((out as TransactionEvent).measurements).toBeUndefined(); + }); + + it('no-ops when there is nothing aggregated', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + integration.setup?.(makeMockClient()); + + const event = makeTransactionEvent(); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + expect(out.spans).toBeUndefined(); + expect(out.measurements).toBeUndefined(); + }); + + it('respects ignoreTurboModules — those modules are not counted', () => { + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + ignoreTurboModules: ['RNSentry'], + }); + integration.setupOnce?.(); + integration.setup?.(makeMockClient()); + + recordTurboModuleCall({ name: 'RNSentry', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + recordTurboModuleCall({ name: 'Other', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + + const event = makeTransactionEvent(); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + expect(out.spans).toHaveLength(1); + expect(out.spans?.[0]?.data).toMatchObject({ + 'turbo_modules.Other.x.sync.count': 1, + }); + expect(out.spans?.[0]?.data).not.toHaveProperty('turbo_modules.RNSentry.x.sync.count'); + }); + + it('does nothing when enableAggregateStats is false', () => { + const integration = turboModuleContextIntegration({ enableAggregateStats: false }); + integration.setupOnce?.(); + const client = makeMockClient(); + integration.setup?.(client); + + // No interval started. + expect(client.on).not.toHaveBeenCalled(); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + const event = makeTransactionEvent(); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + expect(out.spans).toBeUndefined(); + expect(out.measurements).toBeUndefined(); + }); + }); + + describe('aggregate stats — periodic timer flush', () => { + beforeEach(() => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + jest.useFakeTimers(); + }); + + it('captures a periodic event after the configured interval when data is present', () => { + const integration = turboModuleContextIntegration(); + integration.setupOnce?.(); + const client = makeMockClient(); + integration.setup?.(client); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + + jest.advanceTimersByTime(DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS); + + expect(client.captureEvent).toHaveBeenCalledTimes(1); + const capturedEvent = client.captureEvent.mock.calls[0]?.[0]; + expect(capturedEvent).toMatchObject({ + level: 'info', + tags: { 'event.kind': 'turbo_modules.aggregate' }, + }); + expect(capturedEvent.extra).toMatchObject({ + total_call_count: 1, + unique_methods: 1, + }); + }); + + it('does not fire captureEvent when there is no data to flush', () => { + const integration = turboModuleContextIntegration(); + integration.setupOnce?.(); + const client = makeMockClient(); + integration.setup?.(client); + + jest.advanceTimersByTime(DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS); + + expect(client.captureEvent).not.toHaveBeenCalled(); + }); + + it('does not start the periodic timer when aggregateFlushIntervalMs is 0', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const client = makeMockClient(); + integration.setup?.(client); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + jest.advanceTimersByTime(60_000); + + expect(client.captureEvent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/turbomodule/turboModuleAggregator.test.ts b/packages/core/test/turbomodule/turboModuleAggregator.test.ts new file mode 100644 index 0000000000..efacb87a37 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleAggregator.test.ts @@ -0,0 +1,157 @@ +import { + _resetTurboModuleAggregator, + drainTurboModuleAggregate, + HISTOGRAM_BUCKET_LABELS, + hasTurboModuleAggregateData, + isTurboModuleIgnored, + recordTurboModuleCall, + setIgnoredTurboModules, +} from '../../src/js/turbomodule/turboModuleAggregator'; + +describe('turboModuleAggregator', () => { + afterEach(() => { + _resetTurboModuleAggregator(); + }); + + describe('recordTurboModuleCall', () => { + it('aggregates calls under the same (name, method, kind) key', () => { + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 12, + errored: false, + }); + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 8, + errored: true, + }); + + const snapshot = drainTurboModuleAggregate(); + + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + callCount: 2, + errorCount: 1, + totalDurationMs: 20, + maxDurationMs: 12, + }); + }); + + it('keeps separate buckets for different kinds (sync vs async) of the same method', () => { + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'sync', + durationMs: 1, + errored: false, + }); + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 1, + errored: false, + }); + + const snapshot = drainTurboModuleAggregate(); + + expect(snapshot).toHaveLength(2); + expect(snapshot.map(r => r.kind).sort()).toEqual(['async', 'sync']); + }); + + it('places durations into the correct histogram bucket', () => { + // Buckets: <1ms, <5ms, <20ms, <100ms, <500ms, >=500ms + const durations = [0.5, 4, 10, 50, 200, 1000]; + const expectedBuckets = [1, 1, 1, 1, 1, 1]; + + for (const ms of durations) { + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: ms, errored: false }); + } + + const [row] = drainTurboModuleAggregate(); + + expect(row?.buckets).toEqual(expectedBuckets); + expect(row?.callCount).toBe(6); + }); + + it('treats negative durations as zero (clock-skew artefact)', () => { + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: -42, errored: false }); + + const [row] = drainTurboModuleAggregate(); + + expect(row?.totalDurationMs).toBe(0); + expect(row?.maxDurationMs).toBe(0); + expect(row?.buckets[0]).toBe(1); + }); + + it('exposes one bucket entry per HISTOGRAM_BUCKET_LABELS', () => { + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 1, errored: false }); + const [row] = drainTurboModuleAggregate(); + expect(row?.buckets).toHaveLength(HISTOGRAM_BUCKET_LABELS.length); + }); + }); + + describe('drainTurboModuleAggregate', () => { + it('clears state on drain', () => { + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 1, errored: false }); + + expect(hasTurboModuleAggregateData()).toBe(true); + drainTurboModuleAggregate(); + expect(hasTurboModuleAggregateData()).toBe(false); + expect(drainTurboModuleAggregate()).toEqual([]); + }); + + it('returns a defensive copy — mutating the result does not affect the next snapshot', () => { + // Use a sub-1ms duration so the call lands in bucket[0] (`<1ms`), which + // we then mutate to verify the defensive copy. + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 0.5, errored: false }); + const first = drainTurboModuleAggregate(); + // Mutating freed snapshot must not leak into the live store. + if (first[0]) { + first[0].callCount = 999; + first[0].buckets[0] = 999; + } + + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 0.5, errored: false }); + const second = drainTurboModuleAggregate(); + + expect(second[0]?.callCount).toBe(1); + expect(second[0]?.buckets[0]).toBe(1); + }); + }); + + describe('setIgnoredTurboModules', () => { + it('drops calls for ignored modules but counts calls for others', () => { + setIgnoredTurboModules(['RNSentry']); + recordTurboModuleCall({ name: 'RNSentry', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + recordTurboModuleCall({ name: 'OtherModule', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + + const snapshot = drainTurboModuleAggregate(); + + expect(snapshot).toHaveLength(1); + expect(snapshot[0]?.name).toBe('OtherModule'); + }); + + it('replaces the previous ignore list rather than merging', () => { + setIgnoredTurboModules(['A']); + setIgnoredTurboModules(['B']); + + expect(isTurboModuleIgnored('A')).toBe(false); + expect(isTurboModuleIgnored('B')).toBe(true); + }); + + it('clears the ignore list when called with undefined', () => { + setIgnoredTurboModules(['A']); + setIgnoredTurboModules(undefined); + + expect(isTurboModuleIgnored('A')).toBe(false); + }); + }); +}); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 8cbdecce6e..e4214727d7 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; +import { _resetTurboModuleAggregator, drainTurboModuleAggregate } from '../../src/js/turbomodule/turboModuleAggregator'; import * as tracker from '../../src/js/turbomodule/turboModuleTracker'; import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; @@ -10,6 +11,7 @@ describe('wrapTurboModule', () => { beforeEach(() => { _resetTurboModuleTracker(); + _resetTurboModuleAggregator(); _resetWrappedModules(); scope = new Scope(); // `pushTurboModuleCall` defaults to `getIsolationScope()` (see commit @@ -329,4 +331,51 @@ describe('wrapTurboModule', () => { expect(module.version).toBe('1.0.0'); expect(module.doStuff()).toBe(42); }); + + describe('aggregator integration', () => { + it('records a sync call into the aggregator', () => { + const module = { doStuff: () => 'ok' }; + wrapTurboModule('Mod', module); + + module.doStuff(); + + const snapshot = drainTurboModuleAggregate(); + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ name: 'Mod', method: 'doStuff', kind: 'sync', callCount: 1, errorCount: 0 }); + }); + + it('records a sync throw as an errored sync call', () => { + const module = { + boom: () => { + throw new Error('nope'); + }, + }; + wrapTurboModule('Mod', module); + + expect(() => module.boom()).toThrow('nope'); + + const [row] = drainTurboModuleAggregate(); + expect(row).toMatchObject({ kind: 'sync', callCount: 1, errorCount: 1 }); + }); + + it('records an async resolve as a successful async call', async () => { + const module = { ok: () => Promise.resolve('done') }; + wrapTurboModule('Mod', module); + + await module.ok(); + + const [row] = drainTurboModuleAggregate(); + expect(row).toMatchObject({ kind: 'async', callCount: 1, errorCount: 0 }); + }); + + it('records an async reject as an errored async call', async () => { + const module = { fail: () => Promise.reject(new Error('boom')) }; + wrapTurboModule('Mod', module); + + await expect(module.fail()).rejects.toThrow('boom'); + + const [row] = drainTurboModuleAggregate(); + expect(row).toMatchObject({ kind: 'async', callCount: 1, errorCount: 1 }); + }); + }); }); From 5702b466c701dc110436e4dd393d58aa0d524ca1 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 30 Jun 2026 11:20:58 +0200 Subject: [PATCH 2/2] test(core): Trim TurboModule aggregator tests to the basics Drop edge-case tests in favor of one happy-path check per surface. Co-Authored-By: Claude Opus 4.7 --- .../integrations/turboModuleContext.test.ts | 132 +------------- .../turbomodule/turboModuleAggregator.test.ts | 172 ++++-------------- .../test/turbomodule/wrapTurboModule.test.ts | 58 ++---- 3 files changed, 57 insertions(+), 305 deletions(-) diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index de53a1e275..52113cd43c 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -139,12 +139,12 @@ describe('turboModuleContextIntegration', () => { expect(() => turboModuleContextIntegration().setupOnce!()).not.toThrow(); }); - describe('aggregate stats — processEvent flush', () => { + describe('aggregate stats', () => { beforeEach(() => { jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); }); - it('attaches a turbo_modules.aggregate child span + headline measurements on transaction events', () => { + it('attaches a turbo_modules.aggregate child span + headline measurements on transaction finish', () => { const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); integration.setupOnce?.(); integration.setup?.(makeMockClient()); @@ -168,155 +168,33 @@ describe('turboModuleContextIntegration', () => { const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; expect(out.spans).toHaveLength(1); - expect(out.spans?.[0]).toMatchObject({ - op: TURBO_MODULES_AGGREGATE_OP, - trace_id: 'a'.repeat(32), - parent_span_id: 'b'.repeat(16), - }); + expect(out.spans?.[0]).toMatchObject({ op: TURBO_MODULES_AGGREGATE_OP }); expect(out.spans?.[0]?.data).toMatchObject({ - 'turbo_modules.total_call_count': 2, - 'turbo_modules.total_error_count': 1, - 'turbo_modules.total_duration_ms': 20, 'turbo_modules.RNSentry.captureEnvelope.async.count': 2, 'turbo_modules.RNSentry.captureEnvelope.async.error_count': 1, 'turbo_modules.RNSentry.captureEnvelope.async.total_ms': 20, }); expect(out.measurements).toMatchObject({ 'turbo_modules.call_count': { value: 2, unit: 'none' }, - 'turbo_modules.error_count': { value: 1, unit: 'none' }, 'turbo_modules.total_ms': { value: 20, unit: 'millisecond' }, - 'turbo_modules.top_module_ms': { value: 20, unit: 'millisecond' }, - }); - }); - - it('clears the aggregator after a successful flush', () => { - const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); - integration.setupOnce?.(); - integration.setup?.(makeMockClient()); - - recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - - const firstEvent = makeTransactionEvent(); - integration.processEvent?.(firstEvent, {}, makeMockClient()); - expect(firstEvent.spans).toHaveLength(1); - - // A subsequent transaction with no calls in between gets nothing. - const secondEvent = makeTransactionEvent(); - const secondOut = integration.processEvent?.(secondEvent, {}, makeMockClient()) as TransactionEvent; - expect(secondOut.spans ?? []).toHaveLength(0); - expect(secondOut.measurements ?? {}).toEqual({}); - }); - - it('does not touch non-transaction events', () => { - const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); - integration.setupOnce?.(); - integration.setup?.(makeMockClient()); - - recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - - const errorEvent = { type: undefined, message: 'oops' } as unknown as TransactionEvent; - const out = integration.processEvent?.(errorEvent, {}, makeMockClient()); - expect((out as TransactionEvent).spans).toBeUndefined(); - expect((out as TransactionEvent).measurements).toBeUndefined(); - }); - - it('no-ops when there is nothing aggregated', () => { - const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); - integration.setupOnce?.(); - integration.setup?.(makeMockClient()); - - const event = makeTransactionEvent(); - const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; - expect(out.spans).toBeUndefined(); - expect(out.measurements).toBeUndefined(); - }); - - it('respects ignoreTurboModules — those modules are not counted', () => { - const integration = turboModuleContextIntegration({ - aggregateFlushIntervalMs: 0, - ignoreTurboModules: ['RNSentry'], - }); - integration.setupOnce?.(); - integration.setup?.(makeMockClient()); - - recordTurboModuleCall({ name: 'RNSentry', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - recordTurboModuleCall({ name: 'Other', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - - const event = makeTransactionEvent(); - const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; - expect(out.spans).toHaveLength(1); - expect(out.spans?.[0]?.data).toMatchObject({ - 'turbo_modules.Other.x.sync.count': 1, }); - expect(out.spans?.[0]?.data).not.toHaveProperty('turbo_modules.RNSentry.x.sync.count'); - }); - - it('does nothing when enableAggregateStats is false', () => { - const integration = turboModuleContextIntegration({ enableAggregateStats: false }); - integration.setupOnce?.(); - const client = makeMockClient(); - integration.setup?.(client); - - // No interval started. - expect(client.on).not.toHaveBeenCalled(); - - recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - const event = makeTransactionEvent(); - const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; - expect(out.spans).toBeUndefined(); - expect(out.measurements).toBeUndefined(); - }); - }); - - describe('aggregate stats — periodic timer flush', () => { - beforeEach(() => { - jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); - jest.useFakeTimers(); }); it('captures a periodic event after the configured interval when data is present', () => { + jest.useFakeTimers(); const integration = turboModuleContextIntegration(); integration.setupOnce?.(); const client = makeMockClient(); integration.setup?.(client); recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - jest.advanceTimersByTime(DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS); expect(client.captureEvent).toHaveBeenCalledTimes(1); - const capturedEvent = client.captureEvent.mock.calls[0]?.[0]; - expect(capturedEvent).toMatchObject({ + expect(client.captureEvent.mock.calls[0]?.[0]).toMatchObject({ level: 'info', tags: { 'event.kind': 'turbo_modules.aggregate' }, }); - expect(capturedEvent.extra).toMatchObject({ - total_call_count: 1, - unique_methods: 1, - }); - }); - - it('does not fire captureEvent when there is no data to flush', () => { - const integration = turboModuleContextIntegration(); - integration.setupOnce?.(); - const client = makeMockClient(); - integration.setup?.(client); - - jest.advanceTimersByTime(DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS); - - expect(client.captureEvent).not.toHaveBeenCalled(); - }); - - it('does not start the periodic timer when aggregateFlushIntervalMs is 0', () => { - const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); - integration.setupOnce?.(); - const client = makeMockClient(); - integration.setup?.(client); - - recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - jest.advanceTimersByTime(60_000); - - expect(client.captureEvent).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/core/test/turbomodule/turboModuleAggregator.test.ts b/packages/core/test/turbomodule/turboModuleAggregator.test.ts index efacb87a37..07f14fb8f6 100644 --- a/packages/core/test/turbomodule/turboModuleAggregator.test.ts +++ b/packages/core/test/turbomodule/turboModuleAggregator.test.ts @@ -1,9 +1,6 @@ import { _resetTurboModuleAggregator, drainTurboModuleAggregate, - HISTOGRAM_BUCKET_LABELS, - hasTurboModuleAggregateData, - isTurboModuleIgnored, recordTurboModuleCall, setIgnoredTurboModules, } from '../../src/js/turbomodule/turboModuleAggregator'; @@ -13,145 +10,50 @@ describe('turboModuleAggregator', () => { _resetTurboModuleAggregator(); }); - describe('recordTurboModuleCall', () => { - it('aggregates calls under the same (name, method, kind) key', () => { - recordTurboModuleCall({ - name: 'RNSentry', - method: 'captureEnvelope', - kind: 'async', - durationMs: 12, - errored: false, - }); - recordTurboModuleCall({ - name: 'RNSentry', - method: 'captureEnvelope', - kind: 'async', - durationMs: 8, - errored: true, - }); - - const snapshot = drainTurboModuleAggregate(); - - expect(snapshot).toHaveLength(1); - expect(snapshot[0]).toMatchObject({ - name: 'RNSentry', - method: 'captureEnvelope', - kind: 'async', - callCount: 2, - errorCount: 1, - totalDurationMs: 20, - maxDurationMs: 12, - }); - }); - - it('keeps separate buckets for different kinds (sync vs async) of the same method', () => { - recordTurboModuleCall({ - name: 'RNSentry', - method: 'captureEnvelope', - kind: 'sync', - durationMs: 1, - errored: false, - }); - recordTurboModuleCall({ - name: 'RNSentry', - method: 'captureEnvelope', - kind: 'async', - durationMs: 1, - errored: false, - }); - - const snapshot = drainTurboModuleAggregate(); - - expect(snapshot).toHaveLength(2); - expect(snapshot.map(r => r.kind).sort()).toEqual(['async', 'sync']); - }); - - it('places durations into the correct histogram bucket', () => { - // Buckets: <1ms, <5ms, <20ms, <100ms, <500ms, >=500ms - const durations = [0.5, 4, 10, 50, 200, 1000]; - const expectedBuckets = [1, 1, 1, 1, 1, 1]; - - for (const ms of durations) { - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: ms, errored: false }); - } - - const [row] = drainTurboModuleAggregate(); - - expect(row?.buckets).toEqual(expectedBuckets); - expect(row?.callCount).toBe(6); - }); - - it('treats negative durations as zero (clock-skew artefact)', () => { - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: -42, errored: false }); - - const [row] = drainTurboModuleAggregate(); - - expect(row?.totalDurationMs).toBe(0); - expect(row?.maxDurationMs).toBe(0); - expect(row?.buckets[0]).toBe(1); - }); - - it('exposes one bucket entry per HISTOGRAM_BUCKET_LABELS', () => { - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 1, errored: false }); - const [row] = drainTurboModuleAggregate(); - expect(row?.buckets).toHaveLength(HISTOGRAM_BUCKET_LABELS.length); - }); + it('aggregates calls under the same (name, method, kind) key and clears on drain', () => { + recordTurboModuleCall({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + durationMs: 12, + errored: false, + }); + recordTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', durationMs: 8, errored: true }); + + const snapshot = drainTurboModuleAggregate(); + + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + callCount: 2, + errorCount: 1, + totalDurationMs: 20, + maxDurationMs: 12, + }); + expect(drainTurboModuleAggregate()).toEqual([]); }); - describe('drainTurboModuleAggregate', () => { - it('clears state on drain', () => { - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 1, errored: false }); + it('places durations into the correct histogram bucket', () => { + // Buckets: <1ms, <5ms, <20ms, <100ms, <500ms, >=500ms + for (const ms of [0.5, 4, 10, 50, 200, 1000]) { + recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: ms, errored: false }); + } - expect(hasTurboModuleAggregateData()).toBe(true); - drainTurboModuleAggregate(); - expect(hasTurboModuleAggregateData()).toBe(false); - expect(drainTurboModuleAggregate()).toEqual([]); - }); + const [row] = drainTurboModuleAggregate(); - it('returns a defensive copy — mutating the result does not affect the next snapshot', () => { - // Use a sub-1ms duration so the call lands in bucket[0] (`<1ms`), which - // we then mutate to verify the defensive copy. - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 0.5, errored: false }); - const first = drainTurboModuleAggregate(); - // Mutating freed snapshot must not leak into the live store. - if (first[0]) { - first[0].callCount = 999; - first[0].buckets[0] = 999; - } - - recordTurboModuleCall({ name: 'M', method: 'm', kind: 'sync', durationMs: 0.5, errored: false }); - const second = drainTurboModuleAggregate(); - - expect(second[0]?.callCount).toBe(1); - expect(second[0]?.buckets[0]).toBe(1); - }); + expect(row?.buckets).toEqual([1, 1, 1, 1, 1, 1]); }); - describe('setIgnoredTurboModules', () => { - it('drops calls for ignored modules but counts calls for others', () => { - setIgnoredTurboModules(['RNSentry']); - recordTurboModuleCall({ name: 'RNSentry', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - recordTurboModuleCall({ name: 'OtherModule', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + it('drops calls for modules in the ignore list', () => { + setIgnoredTurboModules(['RNSentry']); + recordTurboModuleCall({ name: 'RNSentry', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + recordTurboModuleCall({ name: 'Other', method: 'x', kind: 'sync', durationMs: 1, errored: false }); - const snapshot = drainTurboModuleAggregate(); + const snapshot = drainTurboModuleAggregate(); - expect(snapshot).toHaveLength(1); - expect(snapshot[0]?.name).toBe('OtherModule'); - }); - - it('replaces the previous ignore list rather than merging', () => { - setIgnoredTurboModules(['A']); - setIgnoredTurboModules(['B']); - - expect(isTurboModuleIgnored('A')).toBe(false); - expect(isTurboModuleIgnored('B')).toBe(true); - }); - - it('clears the ignore list when called with undefined', () => { - setIgnoredTurboModules(['A']); - setIgnoredTurboModules(undefined); - - expect(isTurboModuleIgnored('A')).toBe(false); - }); + expect(snapshot).toHaveLength(1); + expect(snapshot[0]?.name).toBe('Other'); }); }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index e4214727d7..4567a31792 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -332,50 +332,22 @@ describe('wrapTurboModule', () => { expect(module.doStuff()).toBe(42); }); - describe('aggregator integration', () => { - it('records a sync call into the aggregator', () => { - const module = { doStuff: () => 'ok' }; - wrapTurboModule('Mod', module); - - module.doStuff(); - - const snapshot = drainTurboModuleAggregate(); - expect(snapshot).toHaveLength(1); - expect(snapshot[0]).toMatchObject({ name: 'Mod', method: 'doStuff', kind: 'sync', callCount: 1, errorCount: 0 }); - }); - - it('records a sync throw as an errored sync call', () => { - const module = { - boom: () => { - throw new Error('nope'); - }, - }; - wrapTurboModule('Mod', module); - - expect(() => module.boom()).toThrow('nope'); - - const [row] = drainTurboModuleAggregate(); - expect(row).toMatchObject({ kind: 'sync', callCount: 1, errorCount: 1 }); - }); - - it('records an async resolve as a successful async call', async () => { - const module = { ok: () => Promise.resolve('done') }; - wrapTurboModule('Mod', module); - - await module.ok(); - - const [row] = drainTurboModuleAggregate(); - expect(row).toMatchObject({ kind: 'async', callCount: 1, errorCount: 0 }); - }); - - it('records an async reject as an errored async call', async () => { - const module = { fail: () => Promise.reject(new Error('boom')) }; - wrapTurboModule('Mod', module); + it('feeds sync and async calls into the aggregator', async () => { + const module = { + sync: () => 'ok', + asyncOk: () => Promise.resolve('done'), + }; + wrapTurboModule('Mod', module); - await expect(module.fail()).rejects.toThrow('boom'); + module.sync(); + await module.asyncOk(); - const [row] = drainTurboModuleAggregate(); - expect(row).toMatchObject({ kind: 'async', callCount: 1, errorCount: 1 }); - }); + const snapshot = drainTurboModuleAggregate(); + expect(snapshot.map(r => ({ method: r.method, kind: r.kind, callCount: r.callCount }))).toEqual( + expect.arrayContaining([ + { method: 'sync', kind: 'sync', callCount: 1 }, + { method: 'asyncOk', kind: 'async', callCount: 1 }, + ]), + ); }); });