From 5d90835206d11ba6045412f3dcc9a2623becfea9 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Jul 2026 15:27:29 +0200 Subject: [PATCH 01/10] feat(core): Attach TurboModule breakdown to active spans on spanEnd Adds per-span attribution to `turboModuleContextIntegration`: on root span end, writes `turbo_module...{call_count,duration_ms, error_count}` attributes plus summary keys. Async calls above `slowCallThresholdMs` (default 500ms) also emit a `native.turbo_module` breadcrumb. New knobs: `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan`. Closes #6165. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 6 + packages/core/etc/sentry-react-native.api.md | 3 + .../src/js/integrations/turboModuleContext.ts | 377 +++++++++--------- .../integrations/turboModuleContextFlush.ts | 207 ++++++++++ packages/core/src/js/turbomodule/index.ts | 4 +- .../js/turbomodule/turboModuleAggregator.ts | 44 ++ .../integrations/turboModuleContext.test.ts | 193 ++++++++- .../turbomodule/turboModuleAggregator.test.ts | 32 ++ 8 files changed, 665 insertions(+), 201 deletions(-) create mode 100644 packages/core/src/js/integrations/turboModuleContextFlush.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3272117324..311eeaae90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ ## Unreleased +### Features + +- Attach a per-`(module, method)` TurboModule breakdown to active spans on `spanEnd`, plus `native.turbo_module` breadcrumbs for slow async calls ([#6165](https://github.com/getsentry/sentry-react-native/issues/6165)) + + When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module...{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`. + ### Fixes - Fix iOS screenshots no longer being captured since 8.19.0 (Feedback Widget screenshot, `attachScreenshot`, `Sentry.captureScreenshot()`) by reverting the iOS `SentrySDK.internal` migration from #6380 ([#6491](https://github.com/getsentry/sentry-react-native/pull/6491), [#6497](https://github.com/getsentry/sentry-react-native/issues/6497)) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 2c83a46489..222454a26c 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -872,12 +872,15 @@ export const turboModuleContextIntegration: (options?: TurboModuleContextOptions export interface TurboModuleContextOptions { aggregateFlushIntervalMs?: number; enableAggregateStats?: boolean; + enableSpanAttribution?: boolean; ignoreTurboModules?: ReadonlyArray; + maxTopModulesPerSpan?: number; modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray; }>; + slowCallThresholdMs?: number; } // @public (undocumented) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 78452d924f..dd3c45774a 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -1,37 +1,46 @@ -import type { Client, Event, Integration, TransactionEvent } from '@sentry/core'; +import type { Client, Event, Integration, Span, TransactionEvent } from '@sentry/core'; -import { debug } from '@sentry/core'; +import { addBreadcrumb, debug, spanToJSON } from '@sentry/core'; -import { createSpanJSON } from '../tracing/utils'; import { - drainTurboModuleAggregate, - HISTOGRAM_BUCKET_LABELS, + addTurboModuleRecordObserver, hasTurboModuleAggregateData, + removeTurboModuleRecordObserver, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, - type TurboModuleAggregate, + type TurboModuleRecord, wrapTurboModule, } from '../turbomodule'; +import { isRootSpan } from '../utils/span'; import { getRNSentryModule } from '../wrapper'; +import { + attachAggregateToTransactionEvent, + flushPeriodicAggregate, + roundMs, + TURBO_MODULES_AGGREGATE_OP, + TURBO_MODULES_AGGREGATE_ORIGIN, +} from './turboModuleContextFlush'; -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'; +export { TURBO_MODULES_AGGREGATE_OP, TURBO_MODULES_AGGREGATE_ORIGIN }; -/** Origin string set on the aggregate span so it shows up as auto-instrumented. */ -export const TURBO_MODULES_AGGREGATE_ORIGIN = 'auto.tracing.turbo_modules'; +export const INTEGRATION_NAME = 'TurboModuleContext'; /** Default flush cadence for the periodic timer, in milliseconds. */ export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30_000; +/** Default duration above which an async TurboModule call becomes a breadcrumb. */ +export const DEFAULT_SLOW_CALL_THRESHOLD_MS = 500; + /** - * 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. + * Default cap on the number of `(module, method)` rows serialised as attributes + * on a single active span. Beyond this, the long tail is dropped; the summary + * attributes still reflect the totals. */ -const MAX_AGGREGATE_ATTRIBUTE_ROWS = 64; +export const DEFAULT_MAX_TOP_MODULES_PER_SPAN = 16; + +/** Breadcrumb category for slow-call notifications. */ +export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; export interface TurboModuleContextOptions { /** @@ -86,6 +95,33 @@ export interface TurboModuleContextOptions { * of the per-(module, method, kind) counters. */ ignoreTurboModules?: ReadonlyArray; + + /** + * On `spanEnd`, attach a per-`(module, method)` TurboModule call breakdown + * to root spans as `turbo_module...{call_count,duration_ms,error_count}` + * attributes plus summary keys. Only root spans are attributed so nested + * user spans don't double-count. + * + * Default: `true`. See https://github.com/getsentry/sentry-react-native/issues/6165. + */ + enableSpanAttribution?: boolean; + + /** + * Minimum duration for an async TurboModule call to emit a + * `native.turbo_module` breadcrumb. Sync calls are excluded — they block JS + * and are covered by stall / frozen-frame instrumentation. + * + * Default: `500`. Set to `0` to disable. + */ + slowCallThresholdMs?: number; + + /** + * Maximum `(module, method)` rows serialised as attributes on a single span. + * Beyond this the tail is dropped; summary attributes still reflect totals. + * + * Default: `16`. + */ + maxTopModulesPerSpan?: number; } // Methods on RNSentry that must NOT be tracked: @@ -129,18 +165,23 @@ const RNSENTRY_SKIP = [ */ export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { const enableAggregate = options.enableAggregateStats !== false; + const enableSpanAttribution = options.enableSpanAttribution !== false; const flushIntervalMs = options.aggregateFlushIntervalMs ?? DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS; + const slowCallThresholdMs = options.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS; + const maxTopModulesPerSpan = options.maxTopModulesPerSpan ?? DEFAULT_MAX_TOP_MODULES_PER_SPAN; let pendingFlushHandle: ReturnType | undefined; let closed = false; + // WeakMap keeps a leaked span (one that never fires `spanEnd`) GC-eligible; + // the parallel array is what the record observer iterates on the hot path. + const openWindows: WeakMap = new WeakMap(); + const openWindowList: WindowState[] = []; + let recordObserver: ((record: TurboModuleRecord) => void) | undefined; + return { name: INTEGRATION_NAME, setupOnce() { - // Wrap the live RNSentry TurboModule. Other integrations import the same - // instance by reference, so wrapping here transparently tracks every call - // made from JS — including the SDK's own internal envelope/scope sync - // calls, which are the most likely entry points for native crashes. wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP }); for (const entry of options.modules ?? []) { @@ -153,22 +194,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } }, 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) { + if (enableAggregate && flushIntervalMs > 0) { + // Lazy re-arm: keeps idle sessions from churning a recurring timer. setOnFirstTurboModuleRecord(() => { if (closed || pendingFlushHandle !== undefined) { return; @@ -180,6 +207,56 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } + if (enableSpanAttribution) { + recordObserver = (record: TurboModuleRecord): void => { + for (const window of openWindowList) { + recordIntoWindow(window, record); + } + + if (slowCallThresholdMs > 0 && record.kind === 'async' && record.durationMs >= slowCallThresholdMs) { + addBreadcrumb({ + category: TURBO_MODULE_BREADCRUMB_CATEGORY, + level: 'info', + type: 'default', + message: `${record.name}.${record.method} took ${roundMs(record.durationMs)}ms`, + data: { + module: record.name, + method: record.method, + kind: record.kind, + duration_ms: roundMs(record.durationMs), + errored: record.errored, + }, + }); + } + }; + addTurboModuleRecordObserver(recordObserver); + + client.on?.('spanStart', (span: Span) => { + if (!isRootSpan(span)) { + return; + } + if (openWindows.has(span)) { + return; + } + const window: WindowState = { span, counters: new Map() }; + openWindows.set(span, window); + openWindowList.push(window); + }); + + client.on?.('spanEnd', (span: Span) => { + const window = openWindows.get(span); + if (!window) { + return; + } + openWindows.delete(span); + const idx = openWindowList.indexOf(window); + if (idx >= 0) { + openWindowList.splice(idx, 1); + } + attachWindowToSpan(span, window, maxTopModulesPerSpan); + }); + } + client.on?.('close', () => { closed = true; setOnFirstTurboModuleRecord(undefined); @@ -187,6 +264,11 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions clearTimeout(pendingFlushHandle); pendingFlushHandle = undefined; } + if (recordObserver) { + removeTurboModuleRecordObserver(recordObserver); + recordObserver = undefined; + } + openWindowList.length = 0; }); }, processEvent(event: Event): Event { @@ -220,183 +302,80 @@ function stripEmptySentinelTags(event: Event): void { } } -/** - * 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. - * - * Draining here runs before `beforeSendTransaction`, so if a user hook drops - * this transaction, the drained batch is lost. Trade-off is intentional: - * peeking without draining would require send-confirmation bookkeeping across - * events and multiple transactions in flight would double-count. Data loss - * from a dropped transaction is bounded (one interval) and self-heals — the - * next transaction or periodic flush picks up fresh activity. - */ -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); +interface WindowRow { + callCount: number; + errorCount: number; + totalDurationMs: number; +} - 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' }; +interface WindowState { + span: Span; + counters: Map; +} - const top = topByTotalMs[0]; - if (top) { - event.measurements['turbo_modules.top_module_ms'] = { - value: roundMs(top.totalDurationMs), - unit: 'millisecond', - }; +function recordIntoWindow(window: WindowState, record: TurboModuleRecord): void { + const key = `${record.name}${record.method}`; + let row = window.counters.get(key); + if (!row) { + row = { callCount: 0, errorCount: 0, totalDurationMs: 0 }; + window.counters.set(key, row); } - - 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.`, - ); + row.callCount += 1; + row.totalDurationMs += record.durationMs; + if (record.errored) { + row.errorCount += 1; } } -/** - * 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. - * - * `client.captureEvent` reaches wrapped `RNSentry.captureEnvelope` via the - * native transport — so if `RNSentry` were aggregated, the flush's own send - * would re-arm the lazy timer indefinitely. `ignoreTurboModules` defaults - * to `['RNSentry']` for exactly this reason. - */ -function flushPeriodicAggregate(client: Client): void { - if (!hasTurboModuleAggregateData()) { +function attachWindowToSpan(span: Span, window: WindowState, topN: number): void { + if (window.counters.size === 0) { 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; + interface FlatRow extends WindowRow { + name: string; + method: string; + } + const rows: FlatRow[] = []; + let totalCallCount = 0; + let totalErrorCount = 0; let totalDurationMs = 0; - for (const row of snapshot) { - callCount += row.callCount; - errorCount += row.errorCount; + for (const [key, row] of window.counters) { + const sep = key.indexOf(''); + const name = key.slice(0, sep); + const method = key.slice(sep + 1); + rows.push({ name, method, ...row }); + totalCallCount += row.callCount; + totalErrorCount += row.errorCount; totalDurationMs += row.totalDurationMs; } - return { callCount, errorCount, totalDurationMs }; -} + rows.sort((a, b) => b.totalDurationMs - a.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; - } - } + const attributes: Record = { + 'turbo_module.total_call_count': totalCallCount, + 'turbo_module.total_error_count': totalErrorCount, + 'turbo_module.total_duration_ms': roundMs(totalDurationMs), + 'turbo_module.unique_methods': rows.length, + }; + const top = rows[0]; + if (top) { + attributes['turbo_module.top_module'] = `${top.name}.${top.method}`; + attributes['turbo_module.top_module_duration_ms'] = roundMs(top.totalDurationMs); } - 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; - } + const capped = rows.slice(0, topN); + for (const row of capped) { + const prefix = `turbo_module.${row.name}.${row.method}`; + attributes[`${prefix}.call_count`] = row.callCount; + attributes[`${prefix}.duration_ms`] = roundMs(row.totalDurationMs); + attributes[`${prefix}.error_count`] = row.errorCount; + } + if (rows.length > topN) { + const spanId = spanToJSON(span).span_id; + debug.log( + `[TurboModuleContext] Span ${spanId ?? '(unknown)'} touched ${rows.length} unique TurboModule methods, ` + + `truncated to top ${topN} by duration. Summary attributes still reflect the full totals.`, + ); } - 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; + span.setAttributes(attributes); } diff --git a/packages/core/src/js/integrations/turboModuleContextFlush.ts b/packages/core/src/js/integrations/turboModuleContextFlush.ts new file mode 100644 index 0000000000..55ec3bb09c --- /dev/null +++ b/packages/core/src/js/integrations/turboModuleContextFlush.ts @@ -0,0 +1,207 @@ +import type { Client, TransactionEvent } from '@sentry/core'; + +import { debug } from '@sentry/core'; + +import { createSpanJSON } from '../tracing/utils'; +import { + drainTurboModuleAggregate, + HISTOGRAM_BUCKET_LABELS, + hasTurboModuleAggregateData, + type TurboModuleAggregate, +} from '../turbomodule'; + +/** 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'; + +/** + * 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; + +/** + * 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. + * + * Draining here runs before `beforeSendTransaction`, so if a user hook drops + * this transaction, the drained batch is lost. Trade-off is intentional: + * peeking without draining would require send-confirmation bookkeeping across + * events and multiple transactions in flight would double-count. Data loss + * from a dropped transaction is bounded (one interval) and self-heals — the + * next transaction or periodic flush picks up fresh activity. + */ +export 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. + * + * `client.captureEvent` reaches wrapped `RNSentry.captureEnvelope` via the + * native transport — so if `RNSentry` were aggregated, the flush's own send + * would re-arm the lazy timer indefinitely. `ignoreTurboModules` defaults + * to `['RNSentry']` for exactly this reason. + */ +export 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, + }; +} + +/** + * Rounds to two-decimal precision — enough for human-readable totals and + * keeps the JSON payload terse. + */ +export function roundMs(value: number): number { + 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 272d24c6f1..750ae054f4 100644 --- a/packages/core/src/js/turbomodule/index.ts +++ b/packages/core/src/js/turbomodule/index.ts @@ -6,14 +6,16 @@ export { } from './turboModuleTracker'; export type { TurboModuleCall, TurboModuleCallKind } from './turboModuleTracker'; export { + addTurboModuleRecordObserver, drainTurboModuleAggregate, HISTOGRAM_BUCKET_LABELS, HISTOGRAM_BUCKETS_MS, hasTurboModuleAggregateData, recordTurboModuleCall, + removeTurboModuleRecordObserver, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, } from './turboModuleAggregator'; -export type { TurboModuleAggregate } from './turboModuleAggregator'; +export type { TurboModuleAggregate, TurboModuleRecord, TurboModuleRecordObserver } from './turboModuleAggregator'; export { wrapTurboModule } from './wrapTurboModule'; diff --git a/packages/core/src/js/turbomodule/turboModuleAggregator.ts b/packages/core/src/js/turbomodule/turboModuleAggregator.ts index 842c3f5ff9..f8b13789c3 100644 --- a/packages/core/src/js/turbomodule/turboModuleAggregator.ts +++ b/packages/core/src/js/turbomodule/turboModuleAggregator.ts @@ -54,9 +54,20 @@ interface MutableAggregate extends TurboModuleAggregate { buckets: number[]; } +export interface TurboModuleRecord { + name: string; + method: string; + kind: TurboModuleCallKind; + durationMs: number; + errored: boolean; +} + +export type TurboModuleRecordObserver = (record: TurboModuleRecord) => void; + const aggregates = new Map(); const ignoredModules = new Set(); let onFirstRecordAfterEmpty: (() => void) | undefined; +const observers: Set = new Set(); // When `false`, `recordTurboModuleCall` is a no-op. The integration flips // this off when `enableAggregateStats: false` so wrapped TurboModule calls // don't accumulate into a map that nothing ever drains. @@ -158,6 +169,38 @@ export function recordTurboModuleCall(args: { // intentionally swallowed } } + + if (observers.size > 0) { + // Reused across observers to avoid GC churn on the wrap layer hot path. + const record: TurboModuleRecord = { + name: args.name, + method: args.method, + kind: args.kind, + durationMs: duration, + errored: args.errored, + }; + for (const observer of observers) { + try { + observer(record); + } catch { + // A misbehaving observer must not drop records for others. + } + } + } +} + +/** + * Subscribes to per-record notifications. Fires for records that survive the + * `setAggregateRecordingEnabled` / `setIgnoredTurboModules` filters — the same + * set that reaches the aggregate map. Observers run synchronously on the wrap + * hot path, so must be O(1); thrown errors are swallowed. + */ +export function addTurboModuleRecordObserver(observer: TurboModuleRecordObserver): void { + observers.add(observer); +} + +export function removeTurboModuleRecordObserver(observer: TurboModuleRecordObserver): void { + observers.delete(observer); } /** @@ -230,5 +273,6 @@ export function _resetTurboModuleAggregator(): void { aggregates.clear(); ignoredModules.clear(); onFirstRecordAfterEmpty = undefined; + observers.clear(); recordingEnabled = true; } diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 95fbed1bd9..0047b6551a 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -1,11 +1,13 @@ -import type { Client, Event, TransactionEvent } from '@sentry/core'; +import type { Client, Event, Span, TransactionEvent } from '@sentry/core'; import { Scope } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS, + DEFAULT_SLOW_CALL_THRESHOLD_MS, turboModuleContextIntegration, + TURBO_MODULE_BREADCRUMB_CATEGORY, TURBO_MODULES_AGGREGATE_OP, } from '../../src/js/integrations/turboModuleContext'; import * as turboModule from '../../src/js/turbomodule'; @@ -14,6 +16,7 @@ import { hasTurboModuleAggregateData, recordTurboModuleCall, } from '../../src/js/turbomodule/turboModuleAggregator'; +import * as spanUtils from '../../src/js/utils/span'; import * as wrapper from '../../src/js/wrapper'; function makeTransactionEvent(overrides: Partial = {}): TransactionEvent { @@ -38,6 +41,54 @@ function makeMockClient(): Client & { on: jest.Mock; captureEvent: jest.Mock } { } as unknown as Client & { on: jest.Mock; captureEvent: jest.Mock }; } +type ClientHandler = (arg: unknown) => void; + +function makeClientWithSpanHooks(): { + client: Client & { on: jest.Mock; captureEvent: jest.Mock }; + handlers: Map; + emit: (event: string, arg: unknown) => void; +} { + const handlers = new Map(); + const client = { + on: jest.fn((event: string, cb: ClientHandler) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }), + captureEvent: jest.fn(), + } as unknown as Client & { on: jest.Mock; captureEvent: jest.Mock }; + return { + client, + handlers, + emit: (event: string, arg: unknown) => { + for (const cb of handlers.get(event) ?? []) { + cb(arg); + } + }, + }; +} + +function makeFakeSpan(overrides: { spanId?: string } = {}): Span & { + setAttributes: jest.Mock; + spanContext: () => { spanId: string; traceId: string; traceFlags: number }; +} { + const attributes: Record = {}; + return { + setAttributes: jest.fn((next: Record) => { + Object.assign(attributes, next); + }), + spanContext: () => ({ + spanId: overrides.spanId ?? 'span-id', + traceId: 't'.repeat(32), + traceFlags: 1, + }), + __attributes: attributes, + } as unknown as Span & { + setAttributes: jest.Mock; + spanContext: () => { spanId: string; traceId: string; traceFlags: number }; + }; +} + describe('turboModuleContextIntegration', () => { let scope: Scope; @@ -347,4 +398,144 @@ describe('turboModuleContextIntegration', () => { expect(out.measurements ?? {}).toEqual({}); }); }); + + describe('span attribution', () => { + let addBreadcrumbSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + jest.spyOn(spanUtils, 'isRootSpan').mockReturnValue(true); + addBreadcrumbSpy = jest.spyOn(SentryCore, 'addBreadcrumb').mockImplementation(() => {}); + }); + + it('attaches per-(module, method) attributes to root spans on spanEnd', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + recordTurboModuleCall({ name: 'UserMod', method: 'work', kind: 'async', durationMs: 12, errored: false }); + recordTurboModuleCall({ name: 'UserMod', method: 'work', kind: 'async', durationMs: 8, errored: true }); + recordTurboModuleCall({ name: 'Other', method: 'ping', kind: 'sync', durationMs: 1, errored: false }); + + emit('spanEnd', span); + + expect(span.setAttributes).toHaveBeenCalledTimes(1); + const attributes = span.setAttributes.mock.calls[0]?.[0] as Record; + expect(attributes).toMatchObject({ + 'turbo_module.UserMod.work.call_count': 2, + 'turbo_module.UserMod.work.duration_ms': 20, + 'turbo_module.UserMod.work.error_count': 1, + 'turbo_module.Other.ping.call_count': 1, + 'turbo_module.total_call_count': 3, + 'turbo_module.total_error_count': 1, + 'turbo_module.top_module': 'UserMod.work', + }); + }); + + it('caps the per-row attribute payload to maxTopModulesPerSpan', () => { + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + maxTopModulesPerSpan: 2, + }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 100, errored: false }); + recordTurboModuleCall({ name: 'B', method: 'x', kind: 'sync', durationMs: 50, errored: false }); + recordTurboModuleCall({ name: 'C', method: 'x', kind: 'sync', durationMs: 10, errored: false }); + + emit('spanEnd', span); + + const attributes = span.setAttributes.mock.calls[0]?.[0] as Record; + expect(attributes['turbo_module.A.x.call_count']).toBe(1); + expect(attributes['turbo_module.B.x.call_count']).toBe(1); + expect(attributes['turbo_module.C.x.call_count']).toBeUndefined(); + expect(attributes['turbo_module.total_call_count']).toBe(3); + expect(attributes['turbo_module.unique_methods']).toBe(3); + }); + + it('does not attach attributes to non-root spans', () => { + (spanUtils.isRootSpan as jest.Mock).mockReturnValue(false); + + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + emit('spanEnd', span); + + expect(span.setAttributes).not.toHaveBeenCalled(); + }); + + it('is inert when enableSpanAttribution is disabled', () => { + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + enableSpanAttribution: false, + }); + integration.setupOnce?.(); + const { client, handlers } = makeClientWithSpanHooks(); + integration.setup?.(client); + + expect(handlers.has('spanStart')).toBe(false); + expect(handlers.has('spanEnd')).toBe(false); + }); + + it('emits a native.turbo_module breadcrumb for async calls exceeding the threshold', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client } = makeClientWithSpanHooks(); + integration.setup?.(client); + + recordTurboModuleCall({ + name: 'Slow', + method: 'blocking', + kind: 'async', + durationMs: DEFAULT_SLOW_CALL_THRESHOLD_MS + 50, + errored: false, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + const breadcrumb = addBreadcrumbSpy.mock.calls[0]?.[0]; + expect(breadcrumb).toMatchObject({ + category: TURBO_MODULE_BREADCRUMB_CATEGORY, + level: 'info', + data: expect.objectContaining({ module: 'Slow', method: 'blocking', kind: 'async' }), + }); + }); + + it('does not emit a breadcrumb below the threshold or for sync calls', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client } = makeClientWithSpanHooks(); + integration.setup?.(client); + + recordTurboModuleCall({ + name: 'Fast', + method: 'noop', + kind: 'async', + durationMs: DEFAULT_SLOW_CALL_THRESHOLD_MS - 1, + errored: false, + }); + recordTurboModuleCall({ + name: 'SlowSync', + method: 'block', + kind: 'sync', + durationMs: DEFAULT_SLOW_CALL_THRESHOLD_MS + 100, + errored: false, + }); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/turbomodule/turboModuleAggregator.test.ts b/packages/core/test/turbomodule/turboModuleAggregator.test.ts index b791b8aba5..28251664cc 100644 --- a/packages/core/test/turbomodule/turboModuleAggregator.test.ts +++ b/packages/core/test/turbomodule/turboModuleAggregator.test.ts @@ -1,8 +1,10 @@ import { _resetTurboModuleAggregator, + addTurboModuleRecordObserver, drainTurboModuleAggregate, hasTurboModuleAggregateData, recordTurboModuleCall, + removeTurboModuleRecordObserver, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, @@ -77,6 +79,36 @@ describe('turboModuleAggregator', () => { expect(cb).toHaveBeenCalledTimes(2); }); + it('notifies per-record observers with the same set of records that reach the aggregate', () => { + const observer = jest.fn(); + addTurboModuleRecordObserver(observer); + + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 3, errored: false }); + setIgnoredTurboModules(['B']); + recordTurboModuleCall({ name: 'B', method: 'x', kind: 'sync', durationMs: 3, errored: false }); + recordTurboModuleCall({ name: 'A', method: 'y', kind: 'async', durationMs: 42, errored: true }); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenNthCalledWith(1, { + name: 'A', + method: 'x', + kind: 'sync', + durationMs: 3, + errored: false, + }); + expect(observer).toHaveBeenNthCalledWith(2, { + name: 'A', + method: 'y', + kind: 'async', + durationMs: 42, + errored: true, + }); + + removeTurboModuleRecordObserver(observer); + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 1, errored: false }); + expect(observer).toHaveBeenCalledTimes(2); + }); + it('drops all calls while recording is disabled and clears any existing entries', () => { // Populate the map first, then disable — the disable path must also // evict the existing entries so a subsequent enable/disable cycle From 8feb4c54e0a77e9f8cf42af9d301f3fcc2814426 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Jul 2026 16:15:08 +0200 Subject: [PATCH 02/10] fix(core): Address review comments on TurboModule span attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nested `name→method` counter map so identifiers with any character can't collide (also removes a stray NUL byte in the compound key). - Fire per-record observers even when `enableAggregateStats: false`, and apply `ignoreTurboModules` whenever any consumer of the record path is active. Fixes span attribution + slow-call breadcrumb going silent when aggregate stats are opted out. - Snapshot the set of open windows at call start via a new `notifyTurboModuleCallStart` hook, so async calls that outlive their originating span still credit that span. Late-settling records re-emit `setAttributes` on the (now closed) span. - CHANGELOG entry references PR #6478 instead of the issue. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- .../src/js/integrations/turboModuleContext.ts | 98 +++++++++++--- packages/core/src/js/turbomodule/index.ts | 11 +- .../js/turbomodule/turboModuleAggregator.ts | 128 ++++++++++++------ .../src/js/turbomodule/wrapTurboModule.ts | 18 ++- .../integrations/turboModuleContext.test.ts | 24 ++++ 6 files changed, 214 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 311eeaae90..48e84ae848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Attach a per-`(module, method)` TurboModule breakdown to active spans on `spanEnd`, plus `native.turbo_module` breadcrumbs for slow async calls ([#6165](https://github.com/getsentry/sentry-react-native/issues/6165)) +- Attach a per-`(module, method)` TurboModule breakdown to active spans on `spanEnd`, plus `native.turbo_module` breadcrumbs for slow async calls ([#6478](https://github.com/getsentry/sentry-react-native/pull/6478)) When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module...{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`. diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index dd3c45774a..ba363a9deb 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -3,12 +3,15 @@ import type { Client, Event, Integration, Span, TransactionEvent } from '@sentry import { addBreadcrumb, debug, spanToJSON } from '@sentry/core'; import { + addTurboModuleCallStartObserver, addTurboModuleRecordObserver, hasTurboModuleAggregateData, + removeTurboModuleCallStartObserver, removeTurboModuleRecordObserver, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, + type TurboModuleCallStart, type TurboModuleRecord, wrapTurboModule, } from '../turbomodule'; @@ -177,7 +180,12 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // the parallel array is what the record observer iterates on the hot path. const openWindows: WeakMap = new WeakMap(); const openWindowList: WindowState[] = []; + // Windows open at each in-flight call's start. Keyed by the wrap layer's + // `recordId` so async calls that settle after their originating span has + // ended still get credited to that span. + const pendingCallWindows: Map = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; + let startObserver: ((start: TurboModuleCallStart) => void) | undefined; return { name: INTEGRATION_NAME, @@ -189,7 +197,10 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } setAggregateRecordingEnabled(enableAggregate); - if (enableAggregate) { + // Applied whenever any consumer of the record path is active — the + // aggregate map, span attribution, or the slow-call breadcrumb — so + // RNSentry's own transport calls are filtered from every surface. + if (enableAggregate || enableSpanAttribution) { setIgnoredTurboModules(options.ignoreTurboModules ?? ['RNSentry']); } }, @@ -208,9 +219,33 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } if (enableSpanAttribution) { + startObserver = (start: TurboModuleCallStart): void => { + if (openWindowList.length === 0) { + return; + } + pendingCallWindows.set(start.recordId, openWindowList.slice()); + }; + addTurboModuleCallStartObserver(startObserver); + recordObserver = (record: TurboModuleRecord): void => { - for (const window of openWindowList) { - recordIntoWindow(window, record); + const windows = record.recordId !== undefined ? pendingCallWindows.get(record.recordId) : undefined; + if (windows) { + pendingCallWindows.delete(record.recordId as number); + for (const window of windows) { + recordIntoWindow(window, record); + // If the span has already ended, re-emit the attributes so a + // late-settling async call still lands on the span before the + // parent transaction is serialised. + if (window.closed) { + attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + } + } + } else { + // Fallback for records that arrived without a paired start (e.g. + // sync path or a direct `recordTurboModuleCall` caller in tests). + for (const window of openWindowList) { + recordIntoWindow(window, record); + } } if (slowCallThresholdMs > 0 && record.kind === 'async' && record.durationMs >= slowCallThresholdMs) { @@ -238,7 +273,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (openWindows.has(span)) { return; } - const window: WindowState = { span, counters: new Map() }; + const window: WindowState = { span, closed: false, counters: new Map() }; openWindows.set(span, window); openWindowList.push(window); }); @@ -253,6 +288,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (idx >= 0) { openWindowList.splice(idx, 1); } + window.closed = true; attachWindowToSpan(span, window, maxTopModulesPerSpan); }); } @@ -268,7 +304,12 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions removeTurboModuleRecordObserver(recordObserver); recordObserver = undefined; } + if (startObserver) { + removeTurboModuleCallStartObserver(startObserver); + startObserver = undefined; + } openWindowList.length = 0; + pendingCallWindows.clear(); }); }, processEvent(event: Event): Event { @@ -303,6 +344,8 @@ function stripEmptySentinelTags(event: Event): void { } interface WindowRow { + name: string; + method: string; callCount: number; errorCount: number; totalDurationMs: number; @@ -310,15 +353,31 @@ interface WindowRow { interface WindowState { span: Span; - counters: Map; + // `true` after `spanEnd` fires. Late-settling async calls that were tracked + // via `pendingCallWindows` still credit the window and re-emit + // `setAttributes` on the span so the transaction picks up the update. + closed: boolean; + // Nested `name → method → row` so identifiers with any character (spaces, + // dots, etc.) can never collide with the pair separator. + counters: Map>; } function recordIntoWindow(window: WindowState, record: TurboModuleRecord): void { - const key = `${record.name}${record.method}`; - let row = window.counters.get(key); + let byMethod = window.counters.get(record.name); + if (!byMethod) { + byMethod = new Map(); + window.counters.set(record.name, byMethod); + } + let row = byMethod.get(record.method); if (!row) { - row = { callCount: 0, errorCount: 0, totalDurationMs: 0 }; - window.counters.set(key, row); + row = { + name: record.name, + method: record.method, + callCount: 0, + errorCount: 0, + totalDurationMs: 0, + }; + byMethod.set(record.method, row); } row.callCount += 1; row.totalDurationMs += record.durationMs; @@ -332,22 +391,17 @@ function attachWindowToSpan(span: Span, window: WindowState, topN: number): void return; } - interface FlatRow extends WindowRow { - name: string; - method: string; - } - const rows: FlatRow[] = []; + const rows: WindowRow[] = []; let totalCallCount = 0; let totalErrorCount = 0; let totalDurationMs = 0; - for (const [key, row] of window.counters) { - const sep = key.indexOf(''); - const name = key.slice(0, sep); - const method = key.slice(sep + 1); - rows.push({ name, method, ...row }); - totalCallCount += row.callCount; - totalErrorCount += row.errorCount; - totalDurationMs += row.totalDurationMs; + for (const byMethod of window.counters.values()) { + for (const row of byMethod.values()) { + rows.push(row); + totalCallCount += row.callCount; + totalErrorCount += row.errorCount; + totalDurationMs += row.totalDurationMs; + } } rows.sort((a, b) => b.totalDurationMs - a.totalDurationMs); diff --git a/packages/core/src/js/turbomodule/index.ts b/packages/core/src/js/turbomodule/index.ts index 750ae054f4..03be558a7e 100644 --- a/packages/core/src/js/turbomodule/index.ts +++ b/packages/core/src/js/turbomodule/index.ts @@ -6,16 +6,25 @@ export { } from './turboModuleTracker'; export type { TurboModuleCall, TurboModuleCallKind } from './turboModuleTracker'; export { + addTurboModuleCallStartObserver, addTurboModuleRecordObserver, drainTurboModuleAggregate, HISTOGRAM_BUCKET_LABELS, HISTOGRAM_BUCKETS_MS, hasTurboModuleAggregateData, + notifyTurboModuleCallStart, recordTurboModuleCall, + removeTurboModuleCallStartObserver, removeTurboModuleRecordObserver, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, } from './turboModuleAggregator'; -export type { TurboModuleAggregate, TurboModuleRecord, TurboModuleRecordObserver } from './turboModuleAggregator'; +export type { + TurboModuleAggregate, + TurboModuleCallStart, + TurboModuleCallStartObserver, + TurboModuleRecord, + TurboModuleRecordObserver, +} from './turboModuleAggregator'; export { wrapTurboModule } from './wrapTurboModule'; diff --git a/packages/core/src/js/turbomodule/turboModuleAggregator.ts b/packages/core/src/js/turbomodule/turboModuleAggregator.ts index f8b13789c3..73ed53935e 100644 --- a/packages/core/src/js/turbomodule/turboModuleAggregator.ts +++ b/packages/core/src/js/turbomodule/turboModuleAggregator.ts @@ -60,14 +60,30 @@ export interface TurboModuleRecord { kind: TurboModuleCallKind; durationMs: number; errored: boolean; + /** + * Correlator returned by {@link notifyTurboModuleCallStart}. Present when the + * wrap layer paired the record with a start notification; missing when a + * caller invokes `recordTurboModuleCall` directly (tests, external hooks). + */ + recordId?: number; +} + +export interface TurboModuleCallStart { + recordId: number; + name: string; + method: string; + kind: TurboModuleCallKind; } export type TurboModuleRecordObserver = (record: TurboModuleRecord) => void; +export type TurboModuleCallStartObserver = (start: TurboModuleCallStart) => void; const aggregates = new Map(); const ignoredModules = new Set(); let onFirstRecordAfterEmpty: (() => void) | undefined; const observers: Set = new Set(); +const startObservers: Set = new Set(); +let nextRecordId = 0; // When `false`, `recordTurboModuleCall` is a no-op. The integration flips // this off when `enableAggregateStats: false` so wrapped TurboModule calls // don't accumulate into a map that nothing ever drains. @@ -120,64 +136,64 @@ export function recordTurboModuleCall(args: { kind: TurboModuleCallKind; durationMs: number; errored: boolean; + recordId?: number; }): void { - if (!recordingEnabled) { - return; - } 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); - } + if (recordingEnabled) { + const wasEmpty = aggregates.size === 0; + const key = makeKey(args.name, args.method, args.kind); - entry.callCount += 1; - if (args.errored) { - entry.errorCount += 1; - } - entry.totalDurationMs += duration; - if (duration > entry.maxDurationMs) { - entry.maxDurationMs = duration; - } + 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); + } - 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; + entry.callCount += 1; + if (args.errored) { + entry.errorCount += 1; + } + entry.totalDurationMs += duration; + if (duration > entry.maxDurationMs) { + entry.maxDurationMs = duration; + } - if (wasEmpty && onFirstRecordAfterEmpty) { - // Don't let a misbehaving observer corrupt the aggregate. - try { - onFirstRecordAfterEmpty(); - } catch { - // intentionally swallowed + const bucket = bucketIndexForDuration(duration); + entry.buckets[bucket] = (entry.buckets[bucket] ?? 0) + 1; + + if (wasEmpty && onFirstRecordAfterEmpty) { + try { + onFirstRecordAfterEmpty(); + } catch { + // intentionally swallowed + } } } + // Observers fire regardless of `recordingEnabled` so span attribution and + // slow-call breadcrumbs work even when aggregate stats are opted out. if (observers.size > 0) { - // Reused across observers to avoid GC churn on the wrap layer hot path. const record: TurboModuleRecord = { name: args.name, method: args.method, kind: args.kind, durationMs: duration, errored: args.errored, + recordId: args.recordId, }; for (const observer of observers) { try { @@ -203,6 +219,40 @@ export function removeTurboModuleRecordObserver(observer: TurboModuleRecordObser observers.delete(observer); } +/** + * Notifies start observers that a TurboModule call is about to run and returns + * a `recordId` correlator. The paired {@link recordTurboModuleCall} passes the + * same id back so consumers (e.g. per-span attribution) can associate the + * settle-time record with state captured at call-start time — matters for + * async calls that outlive the span they started in. + * + * Returns the `recordId` even for ignored modules so the wrap layer never has + * to branch — the paired record for an ignored module will be filtered out. + */ +export function notifyTurboModuleCallStart(name: string, method: string, kind: TurboModuleCallKind): number { + const recordId = nextRecordId++; + if (ignoredModules.has(name) || startObservers.size === 0) { + return recordId; + } + const event: TurboModuleCallStart = { recordId, name, method, kind }; + for (const observer of startObservers) { + try { + observer(event); + } catch { + // A misbehaving observer must not drop the start signal for others. + } + } + return recordId; +} + +export function addTurboModuleCallStartObserver(observer: TurboModuleCallStartObserver): void { + startObservers.add(observer); +} + +export function removeTurboModuleCallStartObserver(observer: TurboModuleCallStartObserver): void { + startObservers.delete(observer); +} + /** * 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 @@ -274,5 +324,7 @@ export function _resetTurboModuleAggregator(): void { ignoredModules.clear(); onFirstRecordAfterEmpty = undefined; observers.clear(); + startObservers.clear(); + nextRecordId = 0; recordingEnabled = true; } diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 26f5e8f7cf..a2ae9afa2c 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -2,7 +2,7 @@ import { logger } from '@sentry/core'; import type { TurboModuleCallKind } from './turboModuleTracker'; -import { recordTurboModuleCall } from './turboModuleAggregator'; +import { notifyTurboModuleCallStart, recordTurboModuleCall } from './turboModuleAggregator'; import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; /** @@ -85,18 +85,24 @@ export function wrapTurboModule( // as sync, relabel to 'async' if the result turns out to be thenable. let callId: number | undefined; const startedAtMs = Date.now(); + let recordId: number | undefined; try { callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); } catch (e) { logger.warn(`[TurboModuleTracker] push failed for ${name}.${key}: ${String(e)}`); } + try { + recordId = notifyTurboModuleCallStart(name, key, 'sync'); + } catch (e) { + logger.warn(`[TurboModuleTracker] notifyStart failed for ${name}.${key}: ${String(e)}`); + } let result: unknown; try { result = originalFn.apply(this, args); } catch (e) { safePop(callId, name, key); - safeRecord(name, key, 'sync', startedAtMs, true); + safeRecord(name, key, 'sync', startedAtMs, true, recordId); throw e; } @@ -105,19 +111,19 @@ export function wrapTurboModule( return (result as Promise).then( value => { safePop(callId, name, key); - safeRecord(name, key, 'async', startedAtMs, false); + safeRecord(name, key, 'async', startedAtMs, false, recordId); return value; }, err => { safePop(callId, name, key); - safeRecord(name, key, 'async', startedAtMs, true); + safeRecord(name, key, 'async', startedAtMs, true, recordId); throw err; }, ); } safePop(callId, name, key); - safeRecord(name, key, 'sync', startedAtMs, false); + safeRecord(name, key, 'sync', startedAtMs, false, recordId); return result; }; @@ -199,6 +205,7 @@ function safeRecord( kind: TurboModuleCallKind, startedAtMs: number, errored: boolean, + recordId: number | undefined, ): void { try { recordTurboModuleCall({ @@ -207,6 +214,7 @@ function safeRecord( kind, durationMs: Date.now() - startedAtMs, errored, + recordId, }); } catch (e) { logger.warn(`[TurboModuleTracker] record failed for ${name}.${method}: ${String(e)}`); diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 0047b6551a..460790dd9a 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -14,6 +14,7 @@ import * as turboModule from '../../src/js/turbomodule'; import { _resetTurboModuleAggregator, hasTurboModuleAggregateData, + notifyTurboModuleCallStart, recordTurboModuleCall, } from '../../src/js/turbomodule/turboModuleAggregator'; import * as spanUtils from '../../src/js/utils/span'; @@ -462,6 +463,29 @@ describe('turboModuleContextIntegration', () => { expect(attributes['turbo_module.unique_methods']).toBe(3); }); + it('credits async calls that started inside the span but settled after it ended', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + // Async call starts during the span, settles after — mirrors what + // `wrapTurboModule` does when a promise-returning method is invoked. + const recordId = notifyTurboModuleCallStart('Late', 'load', 'async'); + emit('spanEnd', span); + recordTurboModuleCall({ name: 'Late', method: 'load', kind: 'async', durationMs: 42, errored: false, recordId }); + + // Only the late record has data — spanEnd's best-effort attach is a + // no-op with nothing to serialise. The record's own attach carries it. + expect(span.setAttributes).toHaveBeenCalledTimes(1); + const attributes = span.setAttributes.mock.calls[0]?.[0] as Record; + expect(attributes['turbo_module.Late.load.call_count']).toBe(1); + expect(attributes['turbo_module.Late.load.duration_ms']).toBe(42); + }); + it('does not attach attributes to non-root spans', () => { (spanUtils.isRootSpan as jest.Mock).mockReturnValue(false); From 352a6f29e7f2287ede556c9594c8dac0670aecb3 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 21 Jul 2026 10:26:18 +0200 Subject: [PATCH 03/10] fix(core): Don't credit a later span for a call that started before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startObserver` used to skip writing to `pendingCallWindows` when the snapshot was empty. When the call settled, `recordObserver` couldn't find the recordId and fell back to the currently-open windows — crediting spans that opened *after* the call started. Common case: an async TurboModule call kicked off during app init, before any nav / user span has opened. Always record a snapshot (even an empty one), and reserve the currently-open fallback for records without a recordId (direct `recordTurboModuleCall` callers in tests). Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 36 +++++++++++-------- .../integrations/turboModuleContext.test.ts | 22 ++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index ba363a9deb..787a4b6b48 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -219,30 +219,36 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } if (enableSpanAttribution) { + // Always record a snapshot — even an empty one — so a call that started + // when no root span was open never gets attributed to spans that + // opened between call start and settle. startObserver = (start: TurboModuleCallStart): void => { - if (openWindowList.length === 0) { - return; - } pendingCallWindows.set(start.recordId, openWindowList.slice()); }; addTurboModuleCallStartObserver(startObserver); recordObserver = (record: TurboModuleRecord): void => { - const windows = record.recordId !== undefined ? pendingCallWindows.get(record.recordId) : undefined; - if (windows) { - pendingCallWindows.delete(record.recordId as number); - for (const window of windows) { - recordIntoWindow(window, record); - // If the span has already ended, re-emit the attributes so a - // late-settling async call still lands on the span before the - // parent transaction is serialised. - if (window.closed) { - attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + if (record.recordId !== undefined) { + const windows = pendingCallWindows.get(record.recordId); + pendingCallWindows.delete(record.recordId); + // `windows` may be an empty array (no spans open at call start). + // Either way, credit only what was captured — the currently-open + // spans opened *after* this call and must not receive its data. + if (windows) { + for (const window of windows) { + recordIntoWindow(window, record); + // If the span has already ended, re-emit the attributes so a + // late-settling async call still lands on the span before the + // parent transaction is serialised. + if (window.closed) { + attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + } } } } else { - // Fallback for records that arrived without a paired start (e.g. - // sync path or a direct `recordTurboModuleCall` caller in tests). + // No `recordId` means the caller bypassed `notifyTurboModuleCallStart` + // (e.g. a direct `recordTurboModuleCall` in tests). Fall back to + // the currently-open windows. for (const window of openWindowList) { recordIntoWindow(window, record); } diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 460790dd9a..487c2770d9 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -486,6 +486,28 @@ describe('turboModuleContextIntegration', () => { expect(attributes['turbo_module.Late.load.duration_ms']).toBe(42); }); + it('does not credit a later span for an async call that started before any span was open', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + // Async call starts with no span open — snapshot must capture "no + // windows" rather than nothing. + const recordId = notifyTurboModuleCallStart('Boot', 'init', 'async'); + + // Span opens *after* the call started. + const span = makeFakeSpan(); + emit('spanStart', span); + + // Call settles into the recordObserver — must not credit `span`. + recordTurboModuleCall({ name: 'Boot', method: 'init', kind: 'async', durationMs: 10, errored: false, recordId }); + + emit('spanEnd', span); + + expect(span.setAttributes).not.toHaveBeenCalled(); + }); + it('does not attach attributes to non-root spans', () => { (spanUtils.isRootSpan as jest.Mock).mockReturnValue(false); From a6da727a93d554cc4b2546be606cfb58e1f427fc Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 22 Jul 2026 10:04:08 +0200 Subject: [PATCH 04/10] fix(core): Clear stale span attributes and cap pending call windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear per-method attribute keys from a previous emit that no longer fit in the top-N. `setAttributes` merges, so without this a late-settling async call that re-ranks the top-N would leave dropped rows on the span. - Cap `pendingCallWindows` at 1024 so abandoned (never-settling) async promises can't pin `WindowState` indefinitely. Evicted entries are silently dropped on late settle rather than mis-attributed. - Correct the misleading comment about the WeakMap — it enables O(1) `spanEnd` lookup; it does not protect against leaks (the parallel `openWindowList` holds strong refs). Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 60 ++++++++++++-- .../integrations/turboModuleContext.test.ts | 82 +++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 787a4b6b48..4efa8d24a9 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -45,6 +45,15 @@ export const DEFAULT_MAX_TOP_MODULES_PER_SPAN = 16; /** Breadcrumb category for slow-call notifications. */ export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; +/** + * Upper bound on `pendingCallWindows` size. Each in-flight async TurboModule + * call adds an entry that's removed when the call settles; a bounded cap keeps + * abandoned (never-settling) promises from pinning `WindowState` forever. + * When exceeded, the oldest entry is dropped — its late-settling record is + * silently ignored rather than mis-attributed to a later span. + */ +export const MAX_PENDING_CALL_WINDOWS = 1024; + export interface TurboModuleContextOptions { /** * Additional TurboModules to track. Each entry's methods will be wrapped so @@ -176,13 +185,17 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions let pendingFlushHandle: ReturnType | undefined; let closed = false; - // WeakMap keeps a leaked span (one that never fires `spanEnd`) GC-eligible; - // the parallel array is what the record observer iterates on the hot path. + // Two structures for the same set of open root spans: the WeakMap gives O(1) + // lookup in `spanEnd`, the parallel array is what the record observer + // iterates on the hot path. Both are cleaned in `spanEnd`; a span that never + // fires `spanEnd` stays pinned via `openWindowList` until `client.close` + // (root spans are always eventually ended in practice, so this is bounded). const openWindows: WeakMap = new WeakMap(); const openWindowList: WindowState[] = []; // Windows open at each in-flight call's start. Keyed by the wrap layer's // `recordId` so async calls that settle after their originating span has - // ended still get credited to that span. + // ended still get credited to that span. Bounded by MAX_PENDING_CALL_WINDOWS + // so a chatty session where some promises never settle can't leak forever. const pendingCallWindows: Map = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; let startObserver: ((start: TurboModuleCallStart) => void) | undefined; @@ -223,6 +236,15 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // when no root span was open never gets attributed to spans that // opened between call start and settle. startObserver = (start: TurboModuleCallStart): void => { + if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { + // Drop oldest entry (Map preserves insertion order). Its record, + // if it ever settles, falls through the `if (windows)` gate and + // is silently ignored — better than mis-attributing to a later span. + const oldest = pendingCallWindows.keys().next().value; + if (oldest !== undefined) { + pendingCallWindows.delete(oldest); + } + } pendingCallWindows.set(start.recordId, openWindowList.slice()); }; addTurboModuleCallStartObserver(startObserver); @@ -366,6 +388,11 @@ interface WindowState { // Nested `name → method → row` so identifiers with any character (spaces, // dots, etc.) can never collide with the pair separator. counters: Map>; + // Per-method attribute keys written on the previous `attachWindowToSpan` + // call. On re-emit (late-settling async), any key not in the new top-N is + // cleared so stale rows don't survive re-ranking. `setAttributes` merges, + // so without this the dropped tail would linger. + writtenPerMethodKeys?: Set; } function recordIntoWindow(window: WindowState, record: TurboModuleRecord): void { @@ -411,7 +438,7 @@ function attachWindowToSpan(span: Span, window: WindowState, topN: number): void } rows.sort((a, b) => b.totalDurationMs - a.totalDurationMs); - const attributes: Record = { + const attributes: Record = { 'turbo_module.total_call_count': totalCallCount, 'turbo_module.total_error_count': totalErrorCount, 'turbo_module.total_duration_ms': roundMs(totalDurationMs), @@ -423,12 +450,31 @@ function attachWindowToSpan(span: Span, window: WindowState, topN: number): void attributes['turbo_module.top_module_duration_ms'] = roundMs(top.totalDurationMs); } const capped = rows.slice(0, topN); + const nextKeys = new Set(); for (const row of capped) { const prefix = `turbo_module.${row.name}.${row.method}`; - attributes[`${prefix}.call_count`] = row.callCount; - attributes[`${prefix}.duration_ms`] = roundMs(row.totalDurationMs); - attributes[`${prefix}.error_count`] = row.errorCount; + const callCountKey = `${prefix}.call_count`; + const durationKey = `${prefix}.duration_ms`; + const errorCountKey = `${prefix}.error_count`; + attributes[callCountKey] = row.callCount; + attributes[durationKey] = roundMs(row.totalDurationMs); + attributes[errorCountKey] = row.errorCount; + nextKeys.add(callCountKey); + nextKeys.add(durationKey); + nextKeys.add(errorCountKey); } + // Clear per-method keys written on a previous emit that no longer fit in the + // top-N. `setAttributes` merges, so a bare re-emit would leave stale rows. + // Setting undefined removes the attribute from the span. + if (window.writtenPerMethodKeys) { + for (const key of window.writtenPerMethodKeys) { + if (!nextKeys.has(key)) { + attributes[key] = undefined; + } + } + } + window.writtenPerMethodKeys = nextKeys; + if (rows.length > topN) { const spanId = spanToJSON(span).span_id; debug.log( diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 487c2770d9..b6edd36a72 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -6,6 +6,7 @@ import * as SentryCore from '@sentry/core'; import { DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS, DEFAULT_SLOW_CALL_THRESHOLD_MS, + MAX_PENDING_CALL_WINDOWS, turboModuleContextIntegration, TURBO_MODULE_BREADCRUMB_CATEGORY, TURBO_MODULES_AGGREGATE_OP, @@ -508,6 +509,87 @@ describe('turboModuleContextIntegration', () => { expect(span.setAttributes).not.toHaveBeenCalled(); }); + it('clears stale per-method keys on re-emit after a late-settling async re-ranks the top-N', () => { + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + maxTopModulesPerSpan: 2, + }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + // A and B fit within maxTopModulesPerSpan=2 at spanEnd. + recordTurboModuleCall({ name: 'A', method: 'x', kind: 'sync', durationMs: 100, errored: false }); + recordTurboModuleCall({ name: 'B', method: 'x', kind: 'sync', durationMs: 50, errored: false }); + + // Async C starts before spanEnd so it captures the window and can credit + // it after spanEnd via `pendingCallWindows`. + const recordId = notifyTurboModuleCallStart('C', 'x', 'async'); + emit('spanEnd', span); + + // Late-settling async C outweighs B and takes B's slot in the top-2. + // Without clearing, B's stale per-method keys would linger on the span + // because `setAttributes` merges. + recordTurboModuleCall({ + name: 'C', + method: 'x', + kind: 'async', + durationMs: 1000, + errored: false, + recordId, + }); + + expect(span.setAttributes).toHaveBeenCalledTimes(2); + const secondCall = span.setAttributes.mock.calls[1]?.[0] as Record; + // The re-emit must explicitly pass undefined for B's per-method keys so + // the merge clears them from the span. Use array form of toHaveProperty + // to match keys that contain dots. + expect(secondCall).toHaveProperty(['turbo_module.B.x.call_count'], undefined); + expect(secondCall).toHaveProperty(['turbo_module.B.x.duration_ms'], undefined); + expect(secondCall).toHaveProperty(['turbo_module.B.x.error_count'], undefined); + // Top-2 now reflects C and A. + expect(secondCall['turbo_module.C.x.duration_ms']).toBe(1000); + expect(secondCall['turbo_module.A.x.call_count']).toBe(1); + }); + + it('caps pendingCallWindows so unresolved async calls do not leak', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + // Start MAX_PENDING_CALL_WINDOWS + 1 async calls without settling them. + // The very first entry must be evicted (oldest-first eviction). + const firstRecordId = notifyTurboModuleCallStart('First', 'work', 'async'); + for (let i = 0; i < MAX_PENDING_CALL_WINDOWS; i++) { + notifyTurboModuleCallStart('Filler', `m${i}`, 'async'); + } + + // Settle the evicted first call — its pending window entry is gone, so + // it must NOT credit `span`. + recordTurboModuleCall({ + name: 'First', + method: 'work', + kind: 'async', + durationMs: 5, + errored: false, + recordId: firstRecordId, + }); + + emit('spanEnd', span); + + // No setAttributes call should ever include the evicted method. + for (const [attributes] of span.setAttributes.mock.calls) { + expect(attributes['turbo_module.First.work.call_count']).toBeUndefined(); + } + }); + it('does not attach attributes to non-root spans', () => { (spanUtils.isRootSpan as jest.Mock).mockReturnValue(false); From 2496c709543c70fbb87d1fae19e7957e9861285f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 22 Jul 2026 14:44:19 +0200 Subject: [PATCH 05/10] fix(core): Decouple slow-call breadcrumbs and skip sync in pending map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register the record observer whenever either `enableSpanAttribution` or `slowCallThresholdMs > 0` is active, and gate span-attribution and breadcrumb logic separately inside. Previously the breadcrumb code lived inside the `enableSpanAttribution` block, so disabling span attribution silently turned off breadcrumbs — contradicting the documented independent `slowCallThresholdMs` knob. `setIgnoredTurboModules` also now applies in breadcrumbs-only mode. - Sync calls skip `pendingCallWindows`. Sync start + settle happen in the same turn, so they can credit `openWindowList` directly. Skipping them also prevents sync bursts from evicting genuine async entries under the MAX_PENDING_CALL_WINDOWS cap. Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 67 ++++++++++------ .../integrations/turboModuleContext.test.ts | 80 +++++++++++++++++++ 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 4efa8d24a9..df8432b2af 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -213,7 +213,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // Applied whenever any consumer of the record path is active — the // aggregate map, span attribution, or the slow-call breadcrumb — so // RNSentry's own transport calls are filtered from every surface. - if (enableAggregate || enableSpanAttribution) { + if (enableAggregate || enableSpanAttribution || slowCallThresholdMs > 0) { setIgnoredTurboModules(options.ignoreTurboModules ?? ['RNSentry']); } }, @@ -231,11 +231,16 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } + // Register the start observer only for span attribution and only for + // async calls. Sync calls settle in the same synchronous turn — window + // state at start and settle is identical, so they can credit + // `openWindowList` directly without a snapshot. Skipping them also + // keeps sync traffic from evicting genuine async entries under the cap. if (enableSpanAttribution) { - // Always record a snapshot — even an empty one — so a call that started - // when no root span was open never gets attributed to spans that - // opened between call start and settle. startObserver = (start: TurboModuleCallStart): void => { + if (start.kind !== 'async') { + return; + } if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { // Drop oldest entry (Map preserves insertion order). Its record, // if it ever settles, falls through the `if (windows)` gate and @@ -248,35 +253,43 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions pendingCallWindows.set(start.recordId, openWindowList.slice()); }; addTurboModuleCallStartObserver(startObserver); + } + // Record observer registers whenever any per-record consumer is active + // (span attribution or slow-call breadcrumbs). Each surface is gated + // separately inside so the two knobs stay independent. + const wantsBreadcrumbs = slowCallThresholdMs > 0; + if (enableSpanAttribution || wantsBreadcrumbs) { recordObserver = (record: TurboModuleRecord): void => { - if (record.recordId !== undefined) { - const windows = pendingCallWindows.get(record.recordId); - pendingCallWindows.delete(record.recordId); - // `windows` may be an empty array (no spans open at call start). - // Either way, credit only what was captured — the currently-open - // spans opened *after* this call and must not receive its data. - if (windows) { - for (const window of windows) { - recordIntoWindow(window, record); - // If the span has already ended, re-emit the attributes so a - // late-settling async call still lands on the span before the - // parent transaction is serialised. - if (window.closed) { - attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + if (enableSpanAttribution) { + if (record.recordId !== undefined && record.kind === 'async') { + const windows = pendingCallWindows.get(record.recordId); + pendingCallWindows.delete(record.recordId); + // `windows` may be an empty array (no spans open at call start). + // Either way, credit only what was captured — the currently-open + // spans opened *after* this call and must not receive its data. + if (windows) { + for (const window of windows) { + recordIntoWindow(window, record); + // If the span has already ended, re-emit the attributes so a + // late-settling async call still lands on the span before the + // parent transaction is serialised. + if (window.closed) { + attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + } } } - } - } else { - // No `recordId` means the caller bypassed `notifyTurboModuleCallStart` - // (e.g. a direct `recordTurboModuleCall` in tests). Fall back to - // the currently-open windows. - for (const window of openWindowList) { - recordIntoWindow(window, record); + } else { + // Sync calls (or records without `recordId`, e.g. a direct + // `recordTurboModuleCall` in tests): sync start + settle happen + // in the same turn, so `openWindowList` is the correct set. + for (const window of openWindowList) { + recordIntoWindow(window, record); + } } } - if (slowCallThresholdMs > 0 && record.kind === 'async' && record.durationMs >= slowCallThresholdMs) { + if (wantsBreadcrumbs && record.kind === 'async' && record.durationMs >= slowCallThresholdMs) { addBreadcrumb({ category: TURBO_MODULE_BREADCRUMB_CATEGORY, level: 'info', @@ -293,7 +306,9 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } }; addTurboModuleRecordObserver(recordObserver); + } + if (enableSpanAttribution) { client.on?.('spanStart', (span: Span) => { if (!isRootSpan(span)) { return; diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index b6edd36a72..9d66d05cad 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -665,5 +665,85 @@ describe('turboModuleContextIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + + it('emits slow-call breadcrumbs even when enableSpanAttribution is disabled', () => { + // `slowCallThresholdMs` is documented as an independent knob — turning + // span attribution off must not silently disable breadcrumbs. + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + enableSpanAttribution: false, + }); + integration.setupOnce?.(); + const { client } = makeClientWithSpanHooks(); + integration.setup?.(client); + + recordTurboModuleCall({ + name: 'Slow', + method: 'blocking', + kind: 'async', + durationMs: DEFAULT_SLOW_CALL_THRESHOLD_MS + 50, + errored: false, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + }); + + it('does not register the record observer when both span attribution and breadcrumbs are off', () => { + const integration = turboModuleContextIntegration({ + aggregateFlushIntervalMs: 0, + enableSpanAttribution: false, + slowCallThresholdMs: 0, + }); + integration.setupOnce?.(); + const { client } = makeClientWithSpanHooks(); + integration.setup?.(client); + + // With every per-record surface disabled, a call must be a no-op — + // no breadcrumb, no attempt to touch a span. + recordTurboModuleCall({ + name: 'X', + method: 'y', + kind: 'async', + durationMs: DEFAULT_SLOW_CALL_THRESHOLD_MS + 100, + errored: false, + }); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('does not evict async pending entries when sync calls fire at cap', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + // A legitimate long-running async call starts and takes a slot. + const asyncRecordId = notifyTurboModuleCallStart('Long', 'req', 'async'); + + // A burst of sync calls hits — with the old design, MAX_PENDING_CALL_WINDOWS + // sync starts would evict `Long`. Sync now skips `pendingCallWindows` + // entirely, so `Long`'s slot survives. + for (let i = 0; i < MAX_PENDING_CALL_WINDOWS + 5; i++) { + notifyTurboModuleCallStart('SyncBurst', `m${i}`, 'sync'); + } + + // `Long` settles after the burst — its window snapshot is still there. + recordTurboModuleCall({ + name: 'Long', + method: 'req', + kind: 'async', + durationMs: 42, + errored: false, + recordId: asyncRecordId, + }); + emit('spanEnd', span); + + const attributes = span.setAttributes.mock.calls.at(-1)?.[0] as Record; + expect(attributes['turbo_module.Long.req.call_count']).toBe(1); + expect(attributes['turbo_module.Long.req.duration_ms']).toBe(42); + }); }); }); From 076488cab3801f8067f2d7222a32b8867fc56838 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 22 Jul 2026 15:21:47 +0200 Subject: [PATCH 06/10] fix(core): Snapshot windows for every kind at call start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wrapTurboModule` always calls `notifyTurboModuleCallStart` with kind `'sync'` and only relabels to `'async'` once the return value is known to be thenable. Gating the start observer on `kind === 'async'` therefore silently dropped every async attribution in production. Revert to snapshotting on every start — sync entries settle in the same synchronous turn and are removed by their paired record, so they don't accumulate under normal traffic. Also add the missing blank line between the Features and Fixes sections in the CHANGELOG. Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 24 +++++++++---------- .../integrations/turboModuleContext.test.ts | 23 ++++++++++++------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index df8432b2af..01a802aa3b 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -231,16 +231,16 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } - // Register the start observer only for span attribution and only for - // async calls. Sync calls settle in the same synchronous turn — window - // state at start and settle is identical, so they can credit - // `openWindowList` directly without a snapshot. Skipping them also - // keeps sync traffic from evicting genuine async entries under the cap. + // Snapshot the open windows at every call start (sync or async). + // `wrapTurboModule` always calls `notifyTurboModuleCallStart` with kind + // `'sync'` and only relabels to `'async'` once the return value is known + // to be thenable — so gating by `start.kind === 'async'` here would + // silently drop *all* async attribution. Sync entries settle in the + // same synchronous turn (their record fires immediately after the + // wrapped method returns) and are removed from the map right away, so + // they don't accumulate under normal traffic. if (enableSpanAttribution) { startObserver = (start: TurboModuleCallStart): void => { - if (start.kind !== 'async') { - return; - } if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { // Drop oldest entry (Map preserves insertion order). Its record, // if it ever settles, falls through the `if (windows)` gate and @@ -262,7 +262,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (enableSpanAttribution || wantsBreadcrumbs) { recordObserver = (record: TurboModuleRecord): void => { if (enableSpanAttribution) { - if (record.recordId !== undefined && record.kind === 'async') { + if (record.recordId !== undefined) { const windows = pendingCallWindows.get(record.recordId); pendingCallWindows.delete(record.recordId); // `windows` may be an empty array (no spans open at call start). @@ -280,9 +280,9 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } } } else { - // Sync calls (or records without `recordId`, e.g. a direct - // `recordTurboModuleCall` in tests): sync start + settle happen - // in the same turn, so `openWindowList` is the correct set. + // No `recordId` means the caller bypassed `notifyTurboModuleCallStart` + // (e.g. a direct `recordTurboModuleCall` in tests). Fall back to + // the currently-open windows. for (const window of openWindowList) { recordIntoWindow(window, record); } diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 9d66d05cad..502515530e 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -711,7 +711,7 @@ describe('turboModuleContextIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); - it('does not evict async pending entries when sync calls fire at cap', () => { + it('does not evict async pending entries when sync calls churn through the pending map', () => { const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); integration.setupOnce?.(); const { client, emit } = makeClientWithSpanHooks(); @@ -720,17 +720,26 @@ describe('turboModuleContextIntegration', () => { const span = makeFakeSpan(); emit('spanStart', span); - // A legitimate long-running async call starts and takes a slot. + // A legitimate long-running async call starts and holds a slot. const asyncRecordId = notifyTurboModuleCallStart('Long', 'req', 'async'); - // A burst of sync calls hits — with the old design, MAX_PENDING_CALL_WINDOWS - // sync starts would evict `Long`. Sync now skips `pendingCallWindows` - // entirely, so `Long`'s slot survives. + // A burst of paired sync notify+record calls (what `wrapTurboModule` + // does for every wrapped invocation before it knows if the return is + // thenable). Each entry lives briefly then is removed by the paired + // record, so the map stays bounded by in-flight async count. for (let i = 0; i < MAX_PENDING_CALL_WINDOWS + 5; i++) { - notifyTurboModuleCallStart('SyncBurst', `m${i}`, 'sync'); + const syncId = notifyTurboModuleCallStart('SyncBurst', `m${i}`, 'sync'); + recordTurboModuleCall({ + name: 'SyncBurst', + method: `m${i}`, + kind: 'sync', + durationMs: 1, + errored: false, + recordId: syncId, + }); } - // `Long` settles after the burst — its window snapshot is still there. + // `Long` settles after the burst — its window snapshot survived. recordTurboModuleCall({ name: 'Long', method: 'req', From 8fb1f050eedc594f5fe53c35ffa9692566d09a1d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 23 Jul 2026 09:53:20 +0200 Subject: [PATCH 07/10] fix(core): Merge late span attribution via processEvent, escape dot keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Late-settling async records after `spanEnd` write via `span.setAttributes` to a frozen span, which the Sentry SDK silently drops. Also buffer the attribute payload by span_id and merge into `event.contexts.trace.data` in `processEvent` so the update lands on the transaction that ships. `MAX_PENDING_SPAN_ATTRIBUTES` caps the buffer against sampled-out transactions never coming back to claim their entry. - Escape `.` in module/method names when composing attribute keys. `turbo_module.${name}.${method}.*` collides otherwise — `(name="a.b", method="c")` and `(name="a", method="b.c")` both produce `turbo_module.a.b.c.*` and overwrite each other. Same fix applied to the aggregate flush serialisation. - Fix the pendingCallWindows cap-eviction test: previously the fillers never settled, so `attachWindowToSpan` returned early on empty counters and the assertion loop never ran. Settle a filler so the eviction outcome is actually verified. Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 119 ++++++++++++++++-- .../integrations/turboModuleContextFlush.ts | 9 +- .../integrations/turboModuleContext.test.ts | 116 ++++++++++++++++- 3 files changed, 231 insertions(+), 13 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 01a802aa3b..9b38bab0bd 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -1,3 +1,4 @@ +/* oxlint-disable eslint(max-lines) */ import type { Client, Event, Integration, Span, TransactionEvent } from '@sentry/core'; import { addBreadcrumb, debug, spanToJSON } from '@sentry/core'; @@ -54,6 +55,16 @@ export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; */ export const MAX_PENDING_CALL_WINDOWS = 1024; +/** + * Upper bound on the `pendingSpanAttributes` buffer. Each ended root span adds + * an entry that's removed when the paired transaction event flows through + * `processEvent`. Sampled-out or otherwise dropped transactions would never + * clear their entry — capping prevents unbounded growth in that edge case. + * Root spans are relatively low-churn (one per navigation / user span) so a + * moderate cap is plenty. + */ +export const MAX_PENDING_SPAN_ATTRIBUTES = 256; + export interface TurboModuleContextOptions { /** * Additional TurboModules to track. Each entry's methods will be wrapped so @@ -197,6 +208,14 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // ended still get credited to that span. Bounded by MAX_PENDING_CALL_WINDOWS // so a chatty session where some promises never settle can't leak forever. const pendingCallWindows: Map = new Map(); + // Latest attribute payload for each ended root span, keyed by span_id. + // `Span#setAttributes` on a frozen span is a no-op in the Sentry SDK, so a + // late-settling async record after `spanEnd` can't reach the sent + // transaction through the span object alone. We buffer here and merge into + // `event.contexts.trace.data` when the paired transaction hits + // `processEvent`. Bounded so a stream of sampled-out transactions doesn't + // pin memory. + const pendingSpanAttributes: Map> = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; let startObserver: ((start: TurboModuleCallStart) => void) | undefined; @@ -275,7 +294,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // late-settling async call still lands on the span before the // parent transaction is serialised. if (window.closed) { - attachWindowToSpan(window.span, window, maxTopModulesPerSpan); + attachWindowToSpan(window.span, window, maxTopModulesPerSpan, pendingSpanAttributes); } } } @@ -332,7 +351,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions openWindowList.splice(idx, 1); } window.closed = true; - attachWindowToSpan(span, window, maxTopModulesPerSpan); + attachWindowToSpan(span, window, maxTopModulesPerSpan, pendingSpanAttributes); }); } @@ -353,6 +372,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } openWindowList.length = 0; pendingCallWindows.clear(); + pendingSpanAttributes.clear(); }); }, processEvent(event: Event): Event { @@ -361,13 +381,31 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions // and flags the event with a Processing Error. See #6502. stripEmptySentinelTags(event); - if (!enableAggregate || event.type !== 'transaction') { + if (event.type !== 'transaction') { return event; } - if (!hasTurboModuleAggregateData()) { - return event; + const txEvent = event as TransactionEvent; + + if (enableAggregate && hasTurboModuleAggregateData()) { + attachAggregateToTransactionEvent(txEvent); + } + + // Merge any span attributes buffered for this transaction's root span. + // `attachWindowToSpan` also called `span.setAttributes` when the record + // came in, but that write is a no-op on a frozen span (late-settling + // async after `spanEnd`). Applying the same payload to + // `event.contexts.trace.data` here is the guaranteed delivery path. + if (enableSpanAttribution) { + const rootSpanId = txEvent.contexts?.trace?.span_id; + if (rootSpanId) { + const pending = pendingSpanAttributes.get(rootSpanId); + if (pending) { + pendingSpanAttributes.delete(rootSpanId); + mergeAttributesIntoTraceData(txEvent, pending); + } + } } - attachAggregateToTransactionEvent(event as TransactionEvent); + return event; }, }; @@ -434,7 +472,12 @@ function recordIntoWindow(window: WindowState, record: TurboModuleRecord): void } } -function attachWindowToSpan(span: Span, window: WindowState, topN: number): void { +function attachWindowToSpan( + span: Span, + window: WindowState, + topN: number, + pendingSpanAttributes: Map>, +): void { if (window.counters.size === 0) { return; } @@ -467,7 +510,11 @@ function attachWindowToSpan(span: Span, window: WindowState, topN: number): void const capped = rows.slice(0, topN); const nextKeys = new Set(); for (const row of capped) { - const prefix = `turbo_module.${row.name}.${row.method}`; + // Sanitise dots in name/method — the attribute key uses `.` as a delimiter, + // so a module named "a.b" with method "c" and a module named "a" with + // method "b.c" would otherwise both produce `turbo_module.a.b.c.*`. RN + // modules don't typically contain dots, but user-provided ones can. + const prefix = `turbo_module.${safeKeyPart(row.name)}.${safeKeyPart(row.method)}`; const callCountKey = `${prefix}.call_count`; const durationKey = `${prefix}.duration_ms`; const errorCountKey = `${prefix}.error_count`; @@ -499,4 +546,60 @@ function attachWindowToSpan(span: Span, window: WindowState, topN: number): void } span.setAttributes(attributes); + + // Also buffer for `processEvent` — `setAttributes` on a frozen span is a + // no-op, so a late-settling async record after `spanEnd` can't land on the + // transaction event through the span object. Applying the same payload to + // `event.contexts.trace.data` at processEvent time is the safety net. + const spanId = spanToJSON(span).span_id; + if (spanId) { + if (!pendingSpanAttributes.has(spanId) && pendingSpanAttributes.size >= MAX_PENDING_SPAN_ATTRIBUTES) { + // Drop the oldest entry (Map preserves insertion order). Sampled-out or + // dropped transactions never come back to reclaim theirs — capping keeps + // the buffer bounded. + const oldest = pendingSpanAttributes.keys().next().value; + if (oldest !== undefined) { + pendingSpanAttributes.delete(oldest); + } + } + pendingSpanAttributes.set(spanId, attributes); + } +} + +/** + * Escapes `.` (the attribute-key delimiter) inside a module/method name so + * `(name="a.b", method="c")` and `(name="a", method="b.c")` don't produce the + * same attribute key. Replacement is not injective in principle — `"a.b"` and + * `"a_b"` would both encode to `"a_b"` — but RN modules don't mix these + * characters mid-identifier, so collisions are effectively impossible in + * practice. + */ +function safeKeyPart(s: string): string { + return s.replace(/\./g, '_'); +} + +/** + * Applies a buffered attribute payload to the root span's data on a + * transaction event. `undefined` values are stripped (Sentry `Span#setAttribute` + * semantics), and other values are merged over any existing keys. + */ +function mergeAttributesIntoTraceData( + event: TransactionEvent, + attributes: Record, +): void { + const trace = event.contexts?.trace; + if (!trace) { + return; + } + const data = { ...((trace.data as Record | undefined) ?? {}) }; + for (const key of Object.keys(attributes)) { + const value = attributes[key]; + if (value === undefined) { + // oxlint-disable-next-line typescript-eslint(no-dynamic-delete) + delete data[key]; + } else { + data[key] = value; + } + } + trace.data = data; } diff --git a/packages/core/src/js/integrations/turboModuleContextFlush.ts b/packages/core/src/js/integrations/turboModuleContextFlush.ts index 55ec3bb09c..a155027cdd 100644 --- a/packages/core/src/js/integrations/turboModuleContextFlush.ts +++ b/packages/core/src/js/integrations/turboModuleContextFlush.ts @@ -152,7 +152,9 @@ function summarise(snapshot: ReadonlyArray): { function serialiseRows(rows: ReadonlyArray): Record { const out: Record = {}; for (const row of rows) { - const prefix = `turbo_modules.${row.name}.${row.method}.${row.kind}`; + // Escape `.` in name/method — attribute keys use `.` as a delimiter, so + // `(name="a.b", method="c")` and `(name="a", method="b.c")` would collide. + const prefix = `turbo_modules.${safeKeyPart(row.name)}.${safeKeyPart(row.method)}.${row.kind}`; out[`${prefix}.count`] = row.callCount; out[`${prefix}.error_count`] = row.errorCount; out[`${prefix}.total_ms`] = roundMs(row.totalDurationMs); @@ -205,3 +207,8 @@ function serialiseRowAsObject(row: TurboModuleAggregate): { export function roundMs(value: number): number { return Math.round(value * 100) / 100; } + +/** Same as the helper in `turboModuleContext.ts` — kept local to avoid a shared util. */ +function safeKeyPart(s: string): string { + return s.replace(/\./g, '_'); +} diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 502515530e..0a2f9f5fcb 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -564,11 +564,27 @@ describe('turboModuleContextIntegration', () => { const span = makeFakeSpan(); emit('spanStart', span); - // Start MAX_PENDING_CALL_WINDOWS + 1 async calls without settling them. - // The very first entry must be evicted (oldest-first eviction). + // Start MAX_PENDING_CALL_WINDOWS + 1 async calls. The very first entry + // must be evicted (oldest-first eviction). const firstRecordId = notifyTurboModuleCallStart('First', 'work', 'async'); + const fillerIds: number[] = []; for (let i = 0; i < MAX_PENDING_CALL_WINDOWS; i++) { - notifyTurboModuleCallStart('Filler', `m${i}`, 'async'); + fillerIds.push(notifyTurboModuleCallStart('Filler', `m${i}`, 'async')); + } + + // Settle a filler so its window credit lands on the span — this drives + // `attachWindowToSpan` on `spanEnd` and gives us setAttributes calls to + // inspect below. + const survivor = fillerIds[0]; + if (survivor !== undefined) { + recordTurboModuleCall({ + name: 'Filler', + method: 'm0', + kind: 'async', + durationMs: 3, + errored: false, + recordId: survivor, + }); } // Settle the evicted first call — its pending window entry is gone, so @@ -584,10 +600,102 @@ describe('turboModuleContextIntegration', () => { emit('spanEnd', span); - // No setAttributes call should ever include the evicted method. + // spanEnd must have flushed the window at least once, and none of the + // resulting payloads may contain the evicted `First.work` row. + expect(span.setAttributes.mock.calls.length).toBeGreaterThan(0); for (const [attributes] of span.setAttributes.mock.calls) { expect(attributes['turbo_module.First.work.call_count']).toBeUndefined(); } + // The surviving filler *did* get credited — that's the positive control + // that keeps this test from silently passing on an empty payload. + const lastCall = span.setAttributes.mock.calls.at(-1)?.[0] as Record; + expect(lastCall['turbo_module.Filler.m0.call_count']).toBe(1); + }); + + it('merges the latest attribute payload onto the transaction event via processEvent', () => { + // The Sentry SDK freezes a span at `.end()`, so a late-settling async + // record after `spanEnd` can't reach the transaction through + // `span.setAttributes` alone. The integration also buffers the payload + // by span_id and merges it into `event.contexts.trace.data` on the + // paired transaction event — this test drives that path. + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan({ spanId: 'root-1' }); + emit('spanStart', span); + + const recordId = notifyTurboModuleCallStart('Late', 'load', 'async'); + emit('spanEnd', span); + recordTurboModuleCall({ + name: 'Late', + method: 'load', + kind: 'async', + durationMs: 42, + errored: false, + recordId, + }); + + const event = makeTransactionEvent({ + contexts: { trace: { trace_id: 'a'.repeat(32), span_id: 'root-1' } }, + }); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + const data = out.contexts?.trace?.data as Record | undefined; + + expect(data).toBeDefined(); + expect(data?.['turbo_module.Late.load.call_count']).toBe(1); + expect(data?.['turbo_module.Late.load.duration_ms']).toBe(42); + expect(data?.['turbo_module.total_call_count']).toBe(1); + }); + + it('does not overwrite pre-existing trace.data keys when merging', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan({ spanId: 'root-2' }); + emit('spanStart', span); + recordTurboModuleCall({ name: 'Mod', method: 'op', kind: 'sync', durationMs: 1, errored: false }); + emit('spanEnd', span); + + const event = makeTransactionEvent({ + contexts: { + trace: { + trace_id: 'a'.repeat(32), + span_id: 'root-2', + data: { 'existing.key': 'kept' }, + }, + }, + }); + const out = integration.processEvent?.(event, {}, makeMockClient()) as TransactionEvent; + const data = out.contexts?.trace?.data as Record; + + expect(data['existing.key']).toBe('kept'); + expect(data['turbo_module.Mod.op.call_count']).toBe(1); + }); + + it('escapes dots in module and method names so distinct pairs never collapse', () => { + // `(name="a.b", method="c")` and `(name="a", method="b.c")` would both + // produce `turbo_module.a.b.c.*` without escaping and overwrite each + // other in the attribute payload. + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + recordTurboModuleCall({ name: 'a.b', method: 'c', kind: 'sync', durationMs: 5, errored: false }); + recordTurboModuleCall({ name: 'a', method: 'b.c', kind: 'sync', durationMs: 7, errored: false }); + emit('spanEnd', span); + + const attributes = span.setAttributes.mock.calls[0]?.[0] as Record; + // Two distinct keys must survive — no collision. + expect(attributes['turbo_module.a_b.c.call_count']).toBe(1); + expect(attributes['turbo_module.a.b_c.call_count']).toBe(1); + expect(attributes['turbo_module.unique_methods']).toBe(2); }); it('does not attach attributes to non-root spans', () => { From 49ba31fc5bb957f27448d6c7418f183c8e8ac446 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 23 Jul 2026 10:26:49 +0200 Subject: [PATCH 08/10] chore(core): Trim comment density in TurboModule integration Prune verbose block comments that restated the code or referenced issues. Kept one-line WHYs where the reasoning is load-bearing (frozen-span setAttributes, dot-collision escape, merge semantics). Co-Authored-By: Claude Opus 4.7 --- .../src/js/integrations/turboModuleContext.ts | 228 +++--------------- .../integrations/turboModuleContextFlush.ts | 43 +--- 2 files changed, 40 insertions(+), 231 deletions(-) diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 9b38bab0bd..8c5febf0c3 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -36,128 +36,46 @@ export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30_000; /** Default duration above which an async TurboModule call becomes a breadcrumb. */ export const DEFAULT_SLOW_CALL_THRESHOLD_MS = 500; -/** - * Default cap on the number of `(module, method)` rows serialised as attributes - * on a single active span. Beyond this, the long tail is dropped; the summary - * attributes still reflect the totals. - */ export const DEFAULT_MAX_TOP_MODULES_PER_SPAN = 16; -/** Breadcrumb category for slow-call notifications. */ export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; -/** - * Upper bound on `pendingCallWindows` size. Each in-flight async TurboModule - * call adds an entry that's removed when the call settles; a bounded cap keeps - * abandoned (never-settling) promises from pinning `WindowState` forever. - * When exceeded, the oldest entry is dropped — its late-settling record is - * silently ignored rather than mis-attributed to a later span. - */ +/** Cap so abandoned (never-settling) promises can't pin `WindowState` forever. */ export const MAX_PENDING_CALL_WINDOWS = 1024; -/** - * Upper bound on the `pendingSpanAttributes` buffer. Each ended root span adds - * an entry that's removed when the paired transaction event flows through - * `processEvent`. Sampled-out or otherwise dropped transactions would never - * clear their entry — capping prevents unbounded growth in that edge case. - * Root spans are relatively low-churn (one per navigation / user span) so a - * moderate cap is plenty. - */ +/** Cap so sampled-out transactions can't leak their buffered attributes. */ export const MAX_PENDING_SPAN_ATTRIBUTES = 256; 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, - * and so the calls are recorded into the aggregator (subject to - * `ignoreTurboModules`). - * - * The built-in `RNSentry` TurboModule is always tracked. - */ + /** Additional TurboModules to wrap. `RNSentry` 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. - */ + /** Per-(module, method, kind) counters, flushed on transaction finish and on a periodic timer. Default: `true`. */ 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). - */ + /** Periodic aggregate flush interval, ms. `0` disables the periodic timer. Default: `30000`. */ aggregateFlushIntervalMs?: number; /** - * TurboModules whose calls should NOT be counted in the aggregate. - * - * Default: `['RNSentry']`. The SDK's own transport call - * (`RNSentry.captureEnvelope`) fires from every `captureEvent`, so leaving - * `RNSentry` in the aggregate would (a) pollute app-level TurboModule - * signals with SDK internal noise and (b) allow the periodic flush's own - * `captureEvent` to record back into the aggregator and perpetually re-arm - * the flush timer in idle sessions. Pass `[]` to opt back in. - * - * 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. + * Modules opted out of the aggregate (still wrapped for crash context). + * Default `['RNSentry']` — the SDK's own transport calls would otherwise + * pollute the signal and self-re-arm the periodic timer indefinitely. */ ignoreTurboModules?: ReadonlyArray; - /** - * On `spanEnd`, attach a per-`(module, method)` TurboModule call breakdown - * to root spans as `turbo_module...{call_count,duration_ms,error_count}` - * attributes plus summary keys. Only root spans are attributed so nested - * user spans don't double-count. - * - * Default: `true`. See https://github.com/getsentry/sentry-react-native/issues/6165. - */ + /** Per-`(module, method)` breakdown on root-span `spanEnd`. Default: `true`. */ enableSpanAttribution?: boolean; - /** - * Minimum duration for an async TurboModule call to emit a - * `native.turbo_module` breadcrumb. Sync calls are excluded — they block JS - * and are covered by stall / frozen-frame instrumentation. - * - * Default: `500`. Set to `0` to disable. - */ + /** Async-call duration above which a `native.turbo_module` breadcrumb fires. `0` disables. Default: `500`. */ slowCallThresholdMs?: number; - /** - * Maximum `(module, method)` rows serialised as attributes on a single span. - * Beyond this the tail is dropped; summary attributes still reflect totals. - * - * Default: `16`. - */ + /** Cap on per-`(module, method)` rows attributed to a single span. Default: `16`. */ maxTopModulesPerSpan?: number; } -// Methods on RNSentry that must NOT be tracked: -// -// - `addListener` / `removeListeners` are RN event-emitter stubs that fire on -// every subscriber registration — tracking them would just churn the scope. -// -// - The scope-sync methods (`setContext`, `setTag`, `setExtra`, `setUser`, -// `addBreadcrumb`, `clearBreadcrumbs`, `setAttribute`, `setAttributes`, -// `removeAttribute`) are called by our own `enableSyncToNative` hook every -// time anything writes to a JS Scope. Tracking them would cause infinite -// recursion: `pushTurboModuleCall` -> `scope.setContext` -> `NATIVE.setContext` -// -> `RNSentry.setContext` (wrapped) -> `pushTurboModuleCall` -> ... . +// Scope-sync methods must NOT be tracked — `enableSyncToNative` calls them on +// every Scope write, so wrapping them would recurse infinitely via +// `pushTurboModuleCall` -> `scope.setContext` -> `RNSentry.setContext`. const RNSENTRY_SKIP = [ 'addListener', 'removeListeners', @@ -173,18 +91,9 @@ const RNSENTRY_SKIP = [ ] as const; /** - * Attaches the currently-executing TurboModule method to the Sentry scope so - * that native crashes can be attributed to the high-level RN module + method - * (e.g. `RNSentry.captureEnvelope`) on top of the native stack trace. - * - * 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 for the - * crash-attribution side of this integration. + * Attributes TurboModule invocations to the Sentry scope for crash context, + * aggregates per-`(module, method, kind)` counters into transaction events, + * attaches a per-span breakdown on `spanEnd`, and emits slow-call breadcrumbs. */ export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { const enableAggregate = options.enableAggregateStats !== false; @@ -196,25 +105,14 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions let pendingFlushHandle: ReturnType | undefined; let closed = false; - // Two structures for the same set of open root spans: the WeakMap gives O(1) - // lookup in `spanEnd`, the parallel array is what the record observer - // iterates on the hot path. Both are cleaned in `spanEnd`; a span that never - // fires `spanEnd` stays pinned via `openWindowList` until `client.close` - // (root spans are always eventually ended in practice, so this is bounded). + // WeakMap: O(1) lookup in spanEnd. Array: hot-path iteration in recordObserver. const openWindows: WeakMap = new WeakMap(); const openWindowList: WindowState[] = []; - // Windows open at each in-flight call's start. Keyed by the wrap layer's - // `recordId` so async calls that settle after their originating span has - // ended still get credited to that span. Bounded by MAX_PENDING_CALL_WINDOWS - // so a chatty session where some promises never settle can't leak forever. + // Keyed by `recordId` so a call that settles after its originating span + // ended still credits that span. const pendingCallWindows: Map = new Map(); - // Latest attribute payload for each ended root span, keyed by span_id. - // `Span#setAttributes` on a frozen span is a no-op in the Sentry SDK, so a - // late-settling async record after `spanEnd` can't reach the sent - // transaction through the span object alone. We buffer here and merge into - // `event.contexts.trace.data` when the paired transaction hits - // `processEvent`. Bounded so a stream of sampled-out transactions doesn't - // pin memory. + // Buffer for `processEvent` merging: `Span#setAttributes` on a frozen span + // is a no-op, so late records after `spanEnd` can only land via the event. const pendingSpanAttributes: Map> = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; let startObserver: ((start: TurboModuleCallStart) => void) | undefined; @@ -229,16 +127,13 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions } setAggregateRecordingEnabled(enableAggregate); - // Applied whenever any consumer of the record path is active — the - // aggregate map, span attribution, or the slow-call breadcrumb — so - // RNSentry's own transport calls are filtered from every surface. if (enableAggregate || enableSpanAttribution || slowCallThresholdMs > 0) { setIgnoredTurboModules(options.ignoreTurboModules ?? ['RNSentry']); } }, setup(client: Client): void { if (enableAggregate && flushIntervalMs > 0) { - // Lazy re-arm: keeps idle sessions from churning a recurring timer. + // Lazy re-arm keeps idle sessions from churning a recurring timer. setOnFirstTurboModuleRecord(() => { if (closed || pendingFlushHandle !== undefined) { return; @@ -250,20 +145,13 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } - // Snapshot the open windows at every call start (sync or async). - // `wrapTurboModule` always calls `notifyTurboModuleCallStart` with kind - // `'sync'` and only relabels to `'async'` once the return value is known - // to be thenable — so gating by `start.kind === 'async'` here would - // silently drop *all* async attribution. Sync entries settle in the - // same synchronous turn (their record fires immediately after the - // wrapped method returns) and are removed from the map right away, so - // they don't accumulate under normal traffic. + // Snapshot on every start (any kind): `wrapTurboModule` always calls + // `notifyTurboModuleCallStart` with `'sync'` and only relabels to + // `'async'` after the return value proves thenable, so gating by kind + // here would silently drop all async attribution. if (enableSpanAttribution) { startObserver = (start: TurboModuleCallStart): void => { if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { - // Drop oldest entry (Map preserves insertion order). Its record, - // if it ever settles, falls through the `if (windows)` gate and - // is silently ignored — better than mis-attributing to a later span. const oldest = pendingCallWindows.keys().next().value; if (oldest !== undefined) { pendingCallWindows.delete(oldest); @@ -274,9 +162,6 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions addTurboModuleCallStartObserver(startObserver); } - // Record observer registers whenever any per-record consumer is active - // (span attribution or slow-call breadcrumbs). Each surface is gated - // separately inside so the two knobs stay independent. const wantsBreadcrumbs = slowCallThresholdMs > 0; if (enableSpanAttribution || wantsBreadcrumbs) { recordObserver = (record: TurboModuleRecord): void => { @@ -284,24 +169,17 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (record.recordId !== undefined) { const windows = pendingCallWindows.get(record.recordId); pendingCallWindows.delete(record.recordId); - // `windows` may be an empty array (no spans open at call start). - // Either way, credit only what was captured — the currently-open - // spans opened *after* this call and must not receive its data. + // Empty `windows` means no spans were open at call start — + // don't fall back to `openWindowList` or we'd credit a later span. if (windows) { for (const window of windows) { recordIntoWindow(window, record); - // If the span has already ended, re-emit the attributes so a - // late-settling async call still lands on the span before the - // parent transaction is serialised. if (window.closed) { attachWindowToSpan(window.span, window, maxTopModulesPerSpan, pendingSpanAttributes); } } } } else { - // No `recordId` means the caller bypassed `notifyTurboModuleCallStart` - // (e.g. a direct `recordTurboModuleCall` in tests). Fall back to - // the currently-open windows. for (const window of openWindowList) { recordIntoWindow(window, record); } @@ -390,11 +268,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions attachAggregateToTransactionEvent(txEvent); } - // Merge any span attributes buffered for this transaction's root span. - // `attachWindowToSpan` also called `span.setAttributes` when the record - // came in, but that write is a no-op on a frozen span (late-settling - // async after `spanEnd`). Applying the same payload to - // `event.contexts.trace.data` here is the guaranteed delivery path. + // Guaranteed-delivery path for span attributes: `setAttributes` on the + // frozen span is a no-op, so late-settling records can only land here. if (enableSpanAttribution) { const rootSpanId = txEvent.contexts?.trace?.span_id; if (rootSpanId) { @@ -434,17 +309,10 @@ interface WindowRow { interface WindowState { span: Span; - // `true` after `spanEnd` fires. Late-settling async calls that were tracked - // via `pendingCallWindows` still credit the window and re-emit - // `setAttributes` on the span so the transaction picks up the update. + /** `true` after `spanEnd` — late records still credit and re-emit. */ closed: boolean; - // Nested `name → method → row` so identifiers with any character (spaces, - // dots, etc.) can never collide with the pair separator. counters: Map>; - // Per-method attribute keys written on the previous `attachWindowToSpan` - // call. On re-emit (late-settling async), any key not in the new top-N is - // cleared so stale rows don't survive re-ranking. `setAttributes` merges, - // so without this the dropped tail would linger. + /** Previously-written top-N keys, used to clear stale ones on re-emit. */ writtenPerMethodKeys?: Set; } @@ -510,10 +378,6 @@ function attachWindowToSpan( const capped = rows.slice(0, topN); const nextKeys = new Set(); for (const row of capped) { - // Sanitise dots in name/method — the attribute key uses `.` as a delimiter, - // so a module named "a.b" with method "c" and a module named "a" with - // method "b.c" would otherwise both produce `turbo_module.a.b.c.*`. RN - // modules don't typically contain dots, but user-provided ones can. const prefix = `turbo_module.${safeKeyPart(row.name)}.${safeKeyPart(row.method)}`; const callCountKey = `${prefix}.call_count`; const durationKey = `${prefix}.duration_ms`; @@ -525,9 +389,8 @@ function attachWindowToSpan( nextKeys.add(durationKey); nextKeys.add(errorCountKey); } - // Clear per-method keys written on a previous emit that no longer fit in the - // top-N. `setAttributes` merges, so a bare re-emit would leave stale rows. - // Setting undefined removes the attribute from the span. + // `setAttributes` merges, so keys dropped from top-N must be explicitly + // cleared with `undefined` or they linger from a previous emit. if (window.writtenPerMethodKeys) { for (const key of window.writtenPerMethodKeys) { if (!nextKeys.has(key)) { @@ -547,16 +410,9 @@ function attachWindowToSpan( span.setAttributes(attributes); - // Also buffer for `processEvent` — `setAttributes` on a frozen span is a - // no-op, so a late-settling async record after `spanEnd` can't land on the - // transaction event through the span object. Applying the same payload to - // `event.contexts.trace.data` at processEvent time is the safety net. const spanId = spanToJSON(span).span_id; if (spanId) { if (!pendingSpanAttributes.has(spanId) && pendingSpanAttributes.size >= MAX_PENDING_SPAN_ATTRIBUTES) { - // Drop the oldest entry (Map preserves insertion order). Sampled-out or - // dropped transactions never come back to reclaim theirs — capping keeps - // the buffer bounded. const oldest = pendingSpanAttributes.keys().next().value; if (oldest !== undefined) { pendingSpanAttributes.delete(oldest); @@ -566,23 +422,11 @@ function attachWindowToSpan( } } -/** - * Escapes `.` (the attribute-key delimiter) inside a module/method name so - * `(name="a.b", method="c")` and `(name="a", method="b.c")` don't produce the - * same attribute key. Replacement is not injective in principle — `"a.b"` and - * `"a_b"` would both encode to `"a_b"` — but RN modules don't mix these - * characters mid-identifier, so collisions are effectively impossible in - * practice. - */ +/** `.` is the attribute-key delimiter — escape it in name/method to avoid collisions. */ function safeKeyPart(s: string): string { return s.replace(/\./g, '_'); } -/** - * Applies a buffered attribute payload to the root span's data on a - * transaction event. `undefined` values are stripped (Sentry `Span#setAttribute` - * semantics), and other values are merged over any existing keys. - */ function mergeAttributesIntoTraceData( event: TransactionEvent, attributes: Record, diff --git a/packages/core/src/js/integrations/turboModuleContextFlush.ts b/packages/core/src/js/integrations/turboModuleContextFlush.ts index a155027cdd..7362cdded6 100644 --- a/packages/core/src/js/integrations/turboModuleContextFlush.ts +++ b/packages/core/src/js/integrations/turboModuleContextFlush.ts @@ -10,29 +10,15 @@ import { type TurboModuleAggregate, } from '../turbomodule'; -/** 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'; -/** - * 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; /** - * 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. - * - * Draining here runs before `beforeSendTransaction`, so if a user hook drops - * this transaction, the drained batch is lost. Trade-off is intentional: - * peeking without draining would require send-confirmation bookkeeping across - * events and multiple transactions in flight would double-count. Data loss - * from a dropped transaction is bounded (one interval) and self-heals — the - * next transaction or periodic flush picks up fresh activity. + * Drains the aggregate into the transaction event as a synthetic child span + * plus headline measurements. Runs before `beforeSendTransaction`, so a + * user-dropped transaction loses its interval — bounded and self-healing. */ export function attachAggregateToTransactionEvent(event: TransactionEvent): void { const trace = event.contexts?.trace; @@ -94,16 +80,7 @@ export function attachAggregateToTransactionEvent(event: TransactionEvent): void } } -/** - * 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. - * - * `client.captureEvent` reaches wrapped `RNSentry.captureEnvelope` via the - * native transport — so if `RNSentry` were aggregated, the flush's own send - * would re-arm the lazy timer indefinitely. `ignoreTurboModules` defaults - * to `['RNSentry']` for exactly this reason. - */ +/** Custom Sentry event so long-running sessions without a transaction still emit a signal. */ export function flushPeriodicAggregate(client: Client): void { if (!hasTurboModuleAggregateData()) { return; @@ -144,16 +121,9 @@ function summarise(snapshot: ReadonlyArray): { 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) { - // Escape `.` in name/method — attribute keys use `.` as a delimiter, so - // `(name="a.b", method="c")` and `(name="a", method="b.c")` would collide. const prefix = `turbo_modules.${safeKeyPart(row.name)}.${safeKeyPart(row.method)}.${row.kind}`; out[`${prefix}.count`] = row.callCount; out[`${prefix}.error_count`] = row.errorCount; @@ -200,15 +170,10 @@ function serialiseRowAsObject(row: TurboModuleAggregate): { }; } -/** - * Rounds to two-decimal precision — enough for human-readable totals and - * keeps the JSON payload terse. - */ export function roundMs(value: number): number { return Math.round(value * 100) / 100; } -/** Same as the helper in `turboModuleContext.ts` — kept local to avoid a shared util. */ function safeKeyPart(s: string): string { return s.replace(/\./g, '_'); } From 643678a254188199f9c046348f454a6756d42648 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 23 Jul 2026 16:13:46 +0200 Subject: [PATCH 09/10] chore(core): Trim comment density and fix safeKeyPart collision Escape `_` as `__` in safeKeyPart so `(a.b, c)` and `(a_b, c)` no longer collapse to the same attribute key, and apply the same escaping to the `turbo_module.top_module` value. Prompted by Cursor Bugbot review. --- packages/core/etc/sentry-react-native.api.md | 11 +- .../src/js/integrations/turboModuleContext.ts | 70 ++-------- .../integrations/turboModuleContextFlush.ts | 11 +- .../js/turbomodule/turboModuleAggregator.ts | 120 +++--------------- .../src/js/turbomodule/wrapTurboModule.ts | 57 ++------- .../integrations/turboModuleContext.test.ts | 21 +++ 6 files changed, 75 insertions(+), 215 deletions(-) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 222454a26c..99b3f9c734 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -865,21 +865,28 @@ export interface TurboModuleCall { // @public export type TurboModuleCallKind = 'sync' | 'async'; -// @public +// @public (undocumented) export const turboModuleContextIntegration: (options?: TurboModuleContextOptions) => Integration; // @public (undocumented) export interface TurboModuleContextOptions { + // (undocumented) aggregateFlushIntervalMs?: number; + // (undocumented) enableAggregateStats?: boolean; + // (undocumented) enableSpanAttribution?: boolean; + // (undocumented) ignoreTurboModules?: ReadonlyArray; + // (undocumented) maxTopModulesPerSpan?: number; + // (undocumented) modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray; }>; + // (undocumented) slowCallThresholdMs?: number; } @@ -930,7 +937,7 @@ export function wrapExpoRouter(router: T): T; // @public export function wrapExpoRouterErrorBoundary

(OriginalErrorBoundary: React_2.ComponentType

): React_2.ComponentType

; -// @public +// @public (undocumented) export function wrapTurboModule(name: string, module: T | null | undefined, options?: { skip?: ReadonlyArray; }): T | null | undefined; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 8c5febf0c3..2ad60f8a96 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -30,51 +30,24 @@ export { TURBO_MODULES_AGGREGATE_OP, TURBO_MODULES_AGGREGATE_ORIGIN }; export const INTEGRATION_NAME = 'TurboModuleContext'; -/** Default flush cadence for the periodic timer, in milliseconds. */ export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30_000; - -/** Default duration above which an async TurboModule call becomes a breadcrumb. */ export const DEFAULT_SLOW_CALL_THRESHOLD_MS = 500; - export const DEFAULT_MAX_TOP_MODULES_PER_SPAN = 16; - export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; - -/** Cap so abandoned (never-settling) promises can't pin `WindowState` forever. */ export const MAX_PENDING_CALL_WINDOWS = 1024; - -/** Cap so sampled-out transactions can't leak their buffered attributes. */ export const MAX_PENDING_SPAN_ATTRIBUTES = 256; export interface TurboModuleContextOptions { - /** Additional TurboModules to wrap. `RNSentry` is always tracked. */ modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; - - /** Per-(module, method, kind) counters, flushed on transaction finish and on a periodic timer. Default: `true`. */ enableAggregateStats?: boolean; - - /** Periodic aggregate flush interval, ms. `0` disables the periodic timer. Default: `30000`. */ aggregateFlushIntervalMs?: number; - - /** - * Modules opted out of the aggregate (still wrapped for crash context). - * Default `['RNSentry']` — the SDK's own transport calls would otherwise - * pollute the signal and self-re-arm the periodic timer indefinitely. - */ ignoreTurboModules?: ReadonlyArray; - - /** Per-`(module, method)` breakdown on root-span `spanEnd`. Default: `true`. */ enableSpanAttribution?: boolean; - - /** Async-call duration above which a `native.turbo_module` breadcrumb fires. `0` disables. Default: `500`. */ slowCallThresholdMs?: number; - - /** Cap on per-`(module, method)` rows attributed to a single span. Default: `16`. */ maxTopModulesPerSpan?: number; } -// Scope-sync methods must NOT be tracked — `enableSyncToNative` calls them on -// every Scope write, so wrapping them would recurse infinitely via +// Wrapping scope-sync methods would recurse infinitely via // `pushTurboModuleCall` -> `scope.setContext` -> `RNSentry.setContext`. const RNSENTRY_SKIP = [ 'addListener', @@ -90,11 +63,6 @@ const RNSENTRY_SKIP = [ 'removeAttribute', ] as const; -/** - * Attributes TurboModule invocations to the Sentry scope for crash context, - * aggregates per-`(module, method, kind)` counters into transaction events, - * attaches a per-span breakdown on `spanEnd`, and emits slow-call breadcrumbs. - */ export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { const enableAggregate = options.enableAggregateStats !== false; const enableSpanAttribution = options.enableSpanAttribution !== false; @@ -105,14 +73,11 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions let pendingFlushHandle: ReturnType | undefined; let closed = false; - // WeakMap: O(1) lookup in spanEnd. Array: hot-path iteration in recordObserver. const openWindows: WeakMap = new WeakMap(); const openWindowList: WindowState[] = []; - // Keyed by `recordId` so a call that settles after its originating span - // ended still credits that span. + // Keyed by `recordId` so a call that settles after its span ended still credits that span. const pendingCallWindows: Map = new Map(); - // Buffer for `processEvent` merging: `Span#setAttributes` on a frozen span - // is a no-op, so late records after `spanEnd` can only land via the event. + // `setAttributes` on a frozen span is a no-op, so late records land here for processEvent merging. const pendingSpanAttributes: Map> = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; let startObserver: ((start: TurboModuleCallStart) => void) | undefined; @@ -145,10 +110,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } - // Snapshot on every start (any kind): `wrapTurboModule` always calls - // `notifyTurboModuleCallStart` with `'sync'` and only relabels to - // `'async'` after the return value proves thenable, so gating by kind - // here would silently drop all async attribution. + // Snapshot on every start regardless of kind — `wrapTurboModule` starts as `'sync'` + // and only relabels to `'async'` post-hoc, so gating by kind would drop all async attribution. if (enableSpanAttribution) { startObserver = (start: TurboModuleCallStart): void => { if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { @@ -169,8 +132,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (record.recordId !== undefined) { const windows = pendingCallWindows.get(record.recordId); pendingCallWindows.delete(record.recordId); - // Empty `windows` means no spans were open at call start — - // don't fall back to `openWindowList` or we'd credit a later span. + // Empty `windows` means no spans were open at call start — don't fall back + // to `openWindowList` or a later span would get credited. if (windows) { for (const window of windows) { recordIntoWindow(window, record); @@ -254,9 +217,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); }, processEvent(event: Event): Event { - // Drop the empty-string sentinel tags written by `clearScope` when no - // TurboModule call is active. Sentry ingestion rejects empty tag values - // and flags the event with a Processing Error. See #6502. + // See #6502: ingestion rejects empty tag values with a Processing Error. stripEmptySentinelTags(event); if (event.type !== 'transaction') { @@ -268,8 +229,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions attachAggregateToTransactionEvent(txEvent); } - // Guaranteed-delivery path for span attributes: `setAttributes` on the - // frozen span is a no-op, so late-settling records can only land here. + // Guaranteed-delivery path for late-settling span attributes (frozen-span setAttributes is a no-op). if (enableSpanAttribution) { const rootSpanId = txEvent.contexts?.trace?.span_id; if (rootSpanId) { @@ -309,10 +269,8 @@ interface WindowRow { interface WindowState { span: Span; - /** `true` after `spanEnd` — late records still credit and re-emit. */ closed: boolean; counters: Map>; - /** Previously-written top-N keys, used to clear stale ones on re-emit. */ writtenPerMethodKeys?: Set; } @@ -372,7 +330,7 @@ function attachWindowToSpan( }; const top = rows[0]; if (top) { - attributes['turbo_module.top_module'] = `${top.name}.${top.method}`; + attributes['turbo_module.top_module'] = `${safeKeyPart(top.name)}.${safeKeyPart(top.method)}`; attributes['turbo_module.top_module_duration_ms'] = roundMs(top.totalDurationMs); } const capped = rows.slice(0, topN); @@ -389,8 +347,7 @@ function attachWindowToSpan( nextKeys.add(durationKey); nextKeys.add(errorCountKey); } - // `setAttributes` merges, so keys dropped from top-N must be explicitly - // cleared with `undefined` or they linger from a previous emit. + // `setAttributes` merges — dropped top-N keys need `undefined` to clear or they linger. if (window.writtenPerMethodKeys) { for (const key of window.writtenPerMethodKeys) { if (!nextKeys.has(key)) { @@ -422,9 +379,10 @@ function attachWindowToSpan( } } -/** `.` is the attribute-key delimiter — escape it in name/method to avoid collisions. */ +// `.` is the attribute-key delimiter — escape to `_` (and pre-escape existing `_` as `__`) +// so `(a.b, c)` and `(a_b, c)` don't collapse to the same key. function safeKeyPart(s: string): string { - return s.replace(/\./g, '_'); + return s.replace(/_/g, '__').replace(/\./g, '_'); } function mergeAttributesIntoTraceData( diff --git a/packages/core/src/js/integrations/turboModuleContextFlush.ts b/packages/core/src/js/integrations/turboModuleContextFlush.ts index 7362cdded6..3b0328ea0e 100644 --- a/packages/core/src/js/integrations/turboModuleContextFlush.ts +++ b/packages/core/src/js/integrations/turboModuleContextFlush.ts @@ -15,11 +15,7 @@ export const TURBO_MODULES_AGGREGATE_ORIGIN = 'auto.tracing.turbo_modules'; const MAX_AGGREGATE_ATTRIBUTE_ROWS = 64; -/** - * Drains the aggregate into the transaction event as a synthetic child span - * plus headline measurements. Runs before `beforeSendTransaction`, so a - * user-dropped transaction loses its interval — bounded and self-healing. - */ +// Runs before `beforeSendTransaction`, so a user-dropped transaction loses one interval — self-healing. export function attachAggregateToTransactionEvent(event: TransactionEvent): void { const trace = event.contexts?.trace; if (!trace?.trace_id || !trace.span_id) { @@ -80,7 +76,6 @@ export function attachAggregateToTransactionEvent(event: TransactionEvent): void } } -/** Custom Sentry event so long-running sessions without a transaction still emit a signal. */ export function flushPeriodicAggregate(client: Client): void { if (!hasTurboModuleAggregateData()) { return; @@ -174,6 +169,8 @@ export function roundMs(value: number): number { return Math.round(value * 100) / 100; } +// `.` is the attribute-key delimiter — escape to `_` (and pre-escape existing `_` as `__`) +// so `(a.b, c)` and `(a_b, c)` don't collapse to the same key. function safeKeyPart(s: string): string { - return s.replace(/\./g, '_'); + return s.replace(/_/g, '__').replace(/\./g, '_'); } diff --git a/packages/core/src/js/turbomodule/turboModuleAggregator.ts b/packages/core/src/js/turbomodule/turboModuleAggregator.ts index 73ed53935e..ea9e7df099 100644 --- a/packages/core/src/js/turbomodule/turboModuleAggregator.ts +++ b/packages/core/src/js/turbomodule/turboModuleAggregator.ts @@ -1,21 +1,12 @@ -/** - * 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. - */ +// Per-(module, method, kind) aggregation of TurboModule invocations. See +// https://github.com/getsentry/sentry-react-native/issues/6164 — one span per +// call would explode span counts on hot async paths, so instead we keep O(1) +// counters and flush at coarse-grained points (transaction finish, periodic timer). 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', @@ -25,28 +16,15 @@ export const HISTOGRAM_BUCKET_LABELS: readonly string[] = [ '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`). - */ + /** Aligned with {@link HISTOGRAM_BUCKETS_MS}; final entry is the `>=500ms` overflow. */ buckets: number[]; } @@ -60,11 +38,6 @@ export interface TurboModuleRecord { kind: TurboModuleCallKind; durationMs: number; errored: boolean; - /** - * Correlator returned by {@link notifyTurboModuleCallStart}. Present when the - * wrap layer paired the record with a start notification; missing when a - * caller invokes `recordTurboModuleCall` directly (tests, external hooks). - */ recordId?: number; } @@ -84,9 +57,6 @@ let onFirstRecordAfterEmpty: (() => void) | undefined; const observers: Set = new Set(); const startObservers: Set = new Set(); let nextRecordId = 0; -// When `false`, `recordTurboModuleCall` is a no-op. The integration flips -// this off when `enableAggregateStats: false` so wrapped TurboModule calls -// don't accumulate into a map that nothing ever drains. let recordingEnabled = true; function makeKey(name: string, method: string, kind: TurboModuleCallKind): string { @@ -95,8 +65,6 @@ function makeKey(name: string, method: string, kind: TurboModuleCallKind): strin 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; @@ -105,13 +73,6 @@ function bucketIndexForDuration(durationMs: number): number { 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) { @@ -122,14 +83,8 @@ export function setIgnoredTurboModules(names: ReadonlyArray | undefined) } } -/** - * 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. - */ +// Must be O(1) — called on every wrapped call. Negative durations (push/pop clock skew) +// are clamped to zero so they still increment counters but don't poison totals. export function recordTurboModuleCall(args: { name: string; method: string; @@ -179,13 +134,13 @@ export function recordTurboModuleCall(args: { try { onFirstRecordAfterEmpty(); } catch { - // intentionally swallowed + // swallowed } } } - // Observers fire regardless of `recordingEnabled` so span attribution and - // slow-call breadcrumbs work even when aggregate stats are opted out. + // Observers fire regardless of `recordingEnabled` — span attribution / slow-call + // breadcrumbs still work when aggregate stats are opted out. if (observers.size > 0) { const record: TurboModuleRecord = { name: args.name, @@ -199,18 +154,12 @@ export function recordTurboModuleCall(args: { try { observer(record); } catch { - // A misbehaving observer must not drop records for others. + // one misbehaving observer must not drop records for others } } } } -/** - * Subscribes to per-record notifications. Fires for records that survive the - * `setAggregateRecordingEnabled` / `setIgnoredTurboModules` filters — the same - * set that reaches the aggregate map. Observers run synchronously on the wrap - * hot path, so must be O(1); thrown errors are swallowed. - */ export function addTurboModuleRecordObserver(observer: TurboModuleRecordObserver): void { observers.add(observer); } @@ -219,16 +168,8 @@ export function removeTurboModuleRecordObserver(observer: TurboModuleRecordObser observers.delete(observer); } -/** - * Notifies start observers that a TurboModule call is about to run and returns - * a `recordId` correlator. The paired {@link recordTurboModuleCall} passes the - * same id back so consumers (e.g. per-span attribution) can associate the - * settle-time record with state captured at call-start time — matters for - * async calls that outlive the span they started in. - * - * Returns the `recordId` even for ignored modules so the wrap layer never has - * to branch — the paired record for an ignored module will be filtered out. - */ +// Returns the `recordId` even for ignored modules so the wrap layer never has +// to branch — the paired record will be filtered out downstream. export function notifyTurboModuleCallStart(name: string, method: string, kind: TurboModuleCallKind): number { const recordId = nextRecordId++; if (ignoredModules.has(name) || startObservers.size === 0) { @@ -239,7 +180,7 @@ export function notifyTurboModuleCallStart(name: string, method: string, kind: T try { observer(event); } catch { - // A misbehaving observer must not drop the start signal for others. + // one misbehaving observer must not drop the signal for others } } return recordId; @@ -253,26 +194,12 @@ export function removeTurboModuleCallStartObserver(observer: TurboModuleCallStar startObservers.delete(observer); } -/** - * 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. - */ +// Fires once when the aggregator transitions empty -> non-empty. Used to lazily +// arm the periodic flush so idle sessions don't churn timers. export function setOnFirstTurboModuleRecord(cb: (() => void) | undefined): void { onFirstRecordAfterEmpty = cb; } -/** - * Master switch for the aggregator. When disabled, `recordTurboModuleCall` - * short-circuits and existing entries are cleared, so wrapped TurboModule - * calls can't accumulate into a map that nothing ever drains (e.g. when the - * integration was constructed with `enableAggregateStats: false`). - * - * Default: enabled. - */ export function setAggregateRecordingEnabled(enabled: boolean): void { recordingEnabled = enabled; if (!enabled) { @@ -280,13 +207,6 @@ export function setAggregateRecordingEnabled(enabled: boolean): void { } } -/** - * 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 []; @@ -308,17 +228,11 @@ export function drainTurboModuleAggregate(): TurboModuleAggregate[] { 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. - */ +/** Tests only. */ export function _resetTurboModuleAggregator(): void { aggregates.clear(); ignoredModules.clear(); diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index a2ae9afa2c..217f75ba98 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -5,10 +5,7 @@ import type { TurboModuleCallKind } from './turboModuleTracker'; import { notifyTurboModuleCallStart, recordTurboModuleCall } from './turboModuleAggregator'; import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; -/** - * Modules we've already wrapped. Tracked off-module so that even sealed proxies - * (which can't accept a marker property) are protected from double-wrapping. - */ +// Tracked off-module so sealed proxies (that can't accept a marker property) are protected from double-wrapping. let wrappedModules = new WeakSet(); /** Tests only. */ @@ -16,19 +13,6 @@ export function _resetWrappedModules(): void { wrappedModules = new WeakSet(); } -/** - * Wraps every function-valued property on the given TurboModule so that each - * invocation is recorded on the Sentry TurboModule tracker. Returns the same - * `module` reference for chaining convenience. - * - * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. - * - Async methods (those returning a thenable) are relabelled to `kind: 'async'` - * right after the call dispatches and popped when the returned promise settles. - * - * `skip` can be used to opt specific method names out of tracking (e.g. very - * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter - * stubs which would otherwise pollute the scope). - */ export function wrapTurboModule( name: string, module: T | null | undefined, @@ -46,8 +30,7 @@ export function wrapTurboModule( const methodNames = collectMethodNames(module); if (methodNames.length === 0) { - // Do NOT add to wrappedModules — a later call (e.g. once a JSI HostObject - // has materialised its methods) should still get a chance to wrap. + // Do NOT mark as wrapped — a later call (once a JSI HostObject materialises its methods) should retry. logger.warn( `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, @@ -61,10 +44,8 @@ export function wrapTurboModule( if (skip.has(key)) { continue; } - // `target[key]` may be a getter (some JSI HostObject proxies expose methods - // as accessors under the New Architecture). Guard the read so a throwing - // getter is treated like a non-wrappable property instead of aborting the - // entire wrap loop. + // JSI HostObject proxies may expose methods as accessors — guard so a throwing getter + // is treated as non-wrappable instead of aborting the whole loop. let original: unknown; try { original = target[key]; @@ -77,12 +58,7 @@ export function wrapTurboModule( const originalFn = original as (...a: unknown[]) => unknown; const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { - // The instrumentation must never break the user's call — every tracker - // interaction is isolated so a failure inside Sentry only drops the - // attribution data, never the real TurboModule invocation. - // - // 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. + // Start optimistic as sync; relabel to 'async' if the return value proves thenable. let callId: number | undefined; const startedAtMs = Date.now(); let recordId: number | undefined; @@ -131,21 +107,14 @@ export function wrapTurboModule( target[key] = wrapper; wrappedAny = true; } catch { - // Sealed / non-writable property — can't intercept this method, but we - // can still wrap the rest. Skip silently. + // sealed / non-writable — skip this method, keep wrapping the rest } } - // Only mark as wrapped if we actually installed at least one wrapper. - // Otherwise a future call (e.g. after the proxy has materialised methods) - // should be allowed to retry. if (wrappedAny) { wrappedModules.add(module); } else { - // We discovered methods but couldn't intercept any of them — e.g. the - // module is frozen, or every method is a read-only accessor. Surface this - // so the silent no-op is debuggable (the issue would otherwise look like - // "no crash context attached" with no obvious cause). + // Methods found but none writable — surface so the silent no-op is debuggable. logger.warn( `[TurboModuleTracker] '${name}' has methods but none could be wrapped — TurboModule context will not be attached. ` + `This usually means the module is frozen or its methods are non-writable accessors.`, @@ -155,13 +124,8 @@ export function wrapTurboModule( return module; } -/** - * Returns the union of own + prototype-chain method names on `module`, - * deduplicated and skipping `Object.prototype`. Walking the prototype chain is - * necessary for JSI HostObject-backed TurboModule proxies under RN's New - * Architecture, which can expose methods via the proto chain rather than as - * own enumerable properties. - */ +// Walks the proto chain — JSI HostObject-backed TurboModule proxies expose methods +// via the proto rather than as own enumerable properties. function collectMethodNames(module: object): string[] { const seen = new Set(); let current: object | null = module; @@ -225,8 +189,7 @@ function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; } - // A user-defined `then` getter could throw — don't let that escape into the - // wrapper (which would leak the in-flight tracker frame on top of the bug). + // A user-defined `then` getter could throw — don't let that leak the tracker frame. try { const then = (value as { then?: unknown }).then; return typeof then === 'function'; diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 0a2f9f5fcb..c9e96c67d7 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -438,6 +438,27 @@ describe('turboModuleContextIntegration', () => { }); }); + it('escapes `.` and `_` so `(a.b, c)` and `(a_b, c)` do not collide on the same key', () => { + const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0 }); + integration.setupOnce?.(); + const { client, emit } = makeClientWithSpanHooks(); + integration.setup?.(client); + + const span = makeFakeSpan(); + emit('spanStart', span); + + recordTurboModuleCall({ name: 'a.b', method: 'c', kind: 'sync', durationMs: 5, errored: false }); + recordTurboModuleCall({ name: 'a_b', method: 'c', kind: 'sync', durationMs: 7, errored: false }); + + emit('spanEnd', span); + + const attributes = span.setAttributes.mock.calls[0]?.[0] as Record; + expect(attributes['turbo_module.a_b.c.call_count']).toBe(1); + expect(attributes['turbo_module.a__b.c.call_count']).toBe(1); + expect(attributes['turbo_module.total_call_count']).toBe(2); + expect(attributes['turbo_module.unique_methods']).toBe(2); + }); + it('caps the per-row attribute payload to maxTopModulesPerSpan', () => { const integration = turboModuleContextIntegration({ aggregateFlushIntervalMs: 0, From 02785182fab42a72e7d4995f5f41079e2bbaabf0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 24 Jul 2026 12:19:10 +0200 Subject: [PATCH 10/10] chore(core): Restore doc comments trimmed by previous commit Keeps the safeKeyPart collision fix from 643678a2 but brings back the JSDoc / WHY comments that were trimmed alongside it. --- packages/core/etc/sentry-react-native.api.md | 11 +- .../src/js/integrations/turboModuleContext.ts | 70 ++++++++-- .../integrations/turboModuleContextFlush.ts | 14 +- .../js/turbomodule/turboModuleAggregator.ts | 120 +++++++++++++++--- .../src/js/turbomodule/wrapTurboModule.ts | 57 +++++++-- 5 files changed, 221 insertions(+), 51 deletions(-) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 99b3f9c734..222454a26c 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -865,28 +865,21 @@ export interface TurboModuleCall { // @public export type TurboModuleCallKind = 'sync' | 'async'; -// @public (undocumented) +// @public export const turboModuleContextIntegration: (options?: TurboModuleContextOptions) => Integration; // @public (undocumented) export interface TurboModuleContextOptions { - // (undocumented) aggregateFlushIntervalMs?: number; - // (undocumented) enableAggregateStats?: boolean; - // (undocumented) enableSpanAttribution?: boolean; - // (undocumented) ignoreTurboModules?: ReadonlyArray; - // (undocumented) maxTopModulesPerSpan?: number; - // (undocumented) modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray; }>; - // (undocumented) slowCallThresholdMs?: number; } @@ -937,7 +930,7 @@ export function wrapExpoRouter(router: T): T; // @public export function wrapExpoRouterErrorBoundary

(OriginalErrorBoundary: React_2.ComponentType

): React_2.ComponentType

; -// @public (undocumented) +// @public export function wrapTurboModule(name: string, module: T | null | undefined, options?: { skip?: ReadonlyArray; }): T | null | undefined; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 2ad60f8a96..f5f75b6a00 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -30,24 +30,51 @@ export { TURBO_MODULES_AGGREGATE_OP, TURBO_MODULES_AGGREGATE_ORIGIN }; export const INTEGRATION_NAME = 'TurboModuleContext'; +/** Default flush cadence for the periodic timer, in milliseconds. */ export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30_000; + +/** Default duration above which an async TurboModule call becomes a breadcrumb. */ export const DEFAULT_SLOW_CALL_THRESHOLD_MS = 500; + export const DEFAULT_MAX_TOP_MODULES_PER_SPAN = 16; + export const TURBO_MODULE_BREADCRUMB_CATEGORY = 'native.turbo_module'; + +/** Cap so abandoned (never-settling) promises can't pin `WindowState` forever. */ export const MAX_PENDING_CALL_WINDOWS = 1024; + +/** Cap so sampled-out transactions can't leak their buffered attributes. */ export const MAX_PENDING_SPAN_ATTRIBUTES = 256; export interface TurboModuleContextOptions { + /** Additional TurboModules to wrap. `RNSentry` is always tracked. */ modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; + + /** Per-(module, method, kind) counters, flushed on transaction finish and on a periodic timer. Default: `true`. */ enableAggregateStats?: boolean; + + /** Periodic aggregate flush interval, ms. `0` disables the periodic timer. Default: `30000`. */ aggregateFlushIntervalMs?: number; + + /** + * Modules opted out of the aggregate (still wrapped for crash context). + * Default `['RNSentry']` — the SDK's own transport calls would otherwise + * pollute the signal and self-re-arm the periodic timer indefinitely. + */ ignoreTurboModules?: ReadonlyArray; + + /** Per-`(module, method)` breakdown on root-span `spanEnd`. Default: `true`. */ enableSpanAttribution?: boolean; + + /** Async-call duration above which a `native.turbo_module` breadcrumb fires. `0` disables. Default: `500`. */ slowCallThresholdMs?: number; + + /** Cap on per-`(module, method)` rows attributed to a single span. Default: `16`. */ maxTopModulesPerSpan?: number; } -// Wrapping scope-sync methods would recurse infinitely via +// Scope-sync methods must NOT be tracked — `enableSyncToNative` calls them on +// every Scope write, so wrapping them would recurse infinitely via // `pushTurboModuleCall` -> `scope.setContext` -> `RNSentry.setContext`. const RNSENTRY_SKIP = [ 'addListener', @@ -63,6 +90,11 @@ const RNSENTRY_SKIP = [ 'removeAttribute', ] as const; +/** + * Attributes TurboModule invocations to the Sentry scope for crash context, + * aggregates per-`(module, method, kind)` counters into transaction events, + * attaches a per-span breakdown on `spanEnd`, and emits slow-call breadcrumbs. + */ export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { const enableAggregate = options.enableAggregateStats !== false; const enableSpanAttribution = options.enableSpanAttribution !== false; @@ -73,11 +105,14 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions let pendingFlushHandle: ReturnType | undefined; let closed = false; + // WeakMap: O(1) lookup in spanEnd. Array: hot-path iteration in recordObserver. const openWindows: WeakMap = new WeakMap(); const openWindowList: WindowState[] = []; - // Keyed by `recordId` so a call that settles after its span ended still credits that span. + // Keyed by `recordId` so a call that settles after its originating span + // ended still credits that span. const pendingCallWindows: Map = new Map(); - // `setAttributes` on a frozen span is a no-op, so late records land here for processEvent merging. + // Buffer for `processEvent` merging: `Span#setAttributes` on a frozen span + // is a no-op, so late records after `spanEnd` can only land via the event. const pendingSpanAttributes: Map> = new Map(); let recordObserver: ((record: TurboModuleRecord) => void) | undefined; let startObserver: ((start: TurboModuleCallStart) => void) | undefined; @@ -110,8 +145,10 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); } - // Snapshot on every start regardless of kind — `wrapTurboModule` starts as `'sync'` - // and only relabels to `'async'` post-hoc, so gating by kind would drop all async attribution. + // Snapshot on every start (any kind): `wrapTurboModule` always calls + // `notifyTurboModuleCallStart` with `'sync'` and only relabels to + // `'async'` after the return value proves thenable, so gating by kind + // here would silently drop all async attribution. if (enableSpanAttribution) { startObserver = (start: TurboModuleCallStart): void => { if (pendingCallWindows.size >= MAX_PENDING_CALL_WINDOWS) { @@ -132,8 +169,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions if (record.recordId !== undefined) { const windows = pendingCallWindows.get(record.recordId); pendingCallWindows.delete(record.recordId); - // Empty `windows` means no spans were open at call start — don't fall back - // to `openWindowList` or a later span would get credited. + // Empty `windows` means no spans were open at call start — + // don't fall back to `openWindowList` or we'd credit a later span. if (windows) { for (const window of windows) { recordIntoWindow(window, record); @@ -217,7 +254,9 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions }); }, processEvent(event: Event): Event { - // See #6502: ingestion rejects empty tag values with a Processing Error. + // Drop the empty-string sentinel tags written by `clearScope` when no + // TurboModule call is active. Sentry ingestion rejects empty tag values + // and flags the event with a Processing Error. See #6502. stripEmptySentinelTags(event); if (event.type !== 'transaction') { @@ -229,7 +268,8 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions attachAggregateToTransactionEvent(txEvent); } - // Guaranteed-delivery path for late-settling span attributes (frozen-span setAttributes is a no-op). + // Guaranteed-delivery path for span attributes: `setAttributes` on the + // frozen span is a no-op, so late-settling records can only land here. if (enableSpanAttribution) { const rootSpanId = txEvent.contexts?.trace?.span_id; if (rootSpanId) { @@ -269,8 +309,10 @@ interface WindowRow { interface WindowState { span: Span; + /** `true` after `spanEnd` — late records still credit and re-emit. */ closed: boolean; counters: Map>; + /** Previously-written top-N keys, used to clear stale ones on re-emit. */ writtenPerMethodKeys?: Set; } @@ -347,7 +389,8 @@ function attachWindowToSpan( nextKeys.add(durationKey); nextKeys.add(errorCountKey); } - // `setAttributes` merges — dropped top-N keys need `undefined` to clear or they linger. + // `setAttributes` merges, so keys dropped from top-N must be explicitly + // cleared with `undefined` or they linger from a previous emit. if (window.writtenPerMethodKeys) { for (const key of window.writtenPerMethodKeys) { if (!nextKeys.has(key)) { @@ -379,8 +422,11 @@ function attachWindowToSpan( } } -// `.` is the attribute-key delimiter — escape to `_` (and pre-escape existing `_` as `__`) -// so `(a.b, c)` and `(a_b, c)` don't collapse to the same key. +/** + * `.` is the attribute-key delimiter — escape it to `_` in name/method so keys + * don't collide. `_` is pre-escaped to `__` so `(a.b, c)` and `(a_b, c)` still + * round-trip to distinct keys. + */ function safeKeyPart(s: string): string { return s.replace(/_/g, '__').replace(/\./g, '_'); } diff --git a/packages/core/src/js/integrations/turboModuleContextFlush.ts b/packages/core/src/js/integrations/turboModuleContextFlush.ts index 3b0328ea0e..8dc70e3a18 100644 --- a/packages/core/src/js/integrations/turboModuleContextFlush.ts +++ b/packages/core/src/js/integrations/turboModuleContextFlush.ts @@ -15,7 +15,11 @@ export const TURBO_MODULES_AGGREGATE_ORIGIN = 'auto.tracing.turbo_modules'; const MAX_AGGREGATE_ATTRIBUTE_ROWS = 64; -// Runs before `beforeSendTransaction`, so a user-dropped transaction loses one interval — self-healing. +/** + * Drains the aggregate into the transaction event as a synthetic child span + * plus headline measurements. Runs before `beforeSendTransaction`, so a + * user-dropped transaction loses its interval — bounded and self-healing. + */ export function attachAggregateToTransactionEvent(event: TransactionEvent): void { const trace = event.contexts?.trace; if (!trace?.trace_id || !trace.span_id) { @@ -76,6 +80,7 @@ export function attachAggregateToTransactionEvent(event: TransactionEvent): void } } +/** Custom Sentry event so long-running sessions without a transaction still emit a signal. */ export function flushPeriodicAggregate(client: Client): void { if (!hasTurboModuleAggregateData()) { return; @@ -169,8 +174,11 @@ export function roundMs(value: number): number { return Math.round(value * 100) / 100; } -// `.` is the attribute-key delimiter — escape to `_` (and pre-escape existing `_` as `__`) -// so `(a.b, c)` and `(a_b, c)` don't collapse to the same key. +/** + * `.` is the attribute-key delimiter — escape it to `_` in name/method so keys + * don't collide. `_` is pre-escaped to `__` so `(a.b, c)` and `(a_b, c)` still + * round-trip to distinct keys. + */ function safeKeyPart(s: string): string { return s.replace(/_/g, '__').replace(/\./g, '_'); } diff --git a/packages/core/src/js/turbomodule/turboModuleAggregator.ts b/packages/core/src/js/turbomodule/turboModuleAggregator.ts index ea9e7df099..73ed53935e 100644 --- a/packages/core/src/js/turbomodule/turboModuleAggregator.ts +++ b/packages/core/src/js/turbomodule/turboModuleAggregator.ts @@ -1,12 +1,21 @@ -// Per-(module, method, kind) aggregation of TurboModule invocations. See -// https://github.com/getsentry/sentry-react-native/issues/6164 — one span per -// call would explode span counts on hot async paths, so instead we keep O(1) -// counters and flush at coarse-grained points (transaction finish, periodic timer). +/** + * 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', @@ -16,15 +25,28 @@ export const HISTOGRAM_BUCKET_LABELS: readonly string[] = [ '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; - /** Aligned with {@link HISTOGRAM_BUCKETS_MS}; final entry is the `>=500ms` overflow. */ + /** + * Per-bucket call counts, aligned with {@link HISTOGRAM_BUCKETS_MS}. The + * final entry is the overflow bucket (`>=500ms`). + */ buckets: number[]; } @@ -38,6 +60,11 @@ export interface TurboModuleRecord { kind: TurboModuleCallKind; durationMs: number; errored: boolean; + /** + * Correlator returned by {@link notifyTurboModuleCallStart}. Present when the + * wrap layer paired the record with a start notification; missing when a + * caller invokes `recordTurboModuleCall` directly (tests, external hooks). + */ recordId?: number; } @@ -57,6 +84,9 @@ let onFirstRecordAfterEmpty: (() => void) | undefined; const observers: Set = new Set(); const startObservers: Set = new Set(); let nextRecordId = 0; +// When `false`, `recordTurboModuleCall` is a no-op. The integration flips +// this off when `enableAggregateStats: false` so wrapped TurboModule calls +// don't accumulate into a map that nothing ever drains. let recordingEnabled = true; function makeKey(name: string, method: string, kind: TurboModuleCallKind): string { @@ -65,6 +95,8 @@ function makeKey(name: string, method: string, kind: TurboModuleCallKind): strin 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; @@ -73,6 +105,13 @@ function bucketIndexForDuration(durationMs: number): number { 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) { @@ -83,8 +122,14 @@ export function setIgnoredTurboModules(names: ReadonlyArray | undefined) } } -// Must be O(1) — called on every wrapped call. Negative durations (push/pop clock skew) -// are clamped to zero so they still increment counters but don't poison totals. +/** + * 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; @@ -134,13 +179,13 @@ export function recordTurboModuleCall(args: { try { onFirstRecordAfterEmpty(); } catch { - // swallowed + // intentionally swallowed } } } - // Observers fire regardless of `recordingEnabled` — span attribution / slow-call - // breadcrumbs still work when aggregate stats are opted out. + // Observers fire regardless of `recordingEnabled` so span attribution and + // slow-call breadcrumbs work even when aggregate stats are opted out. if (observers.size > 0) { const record: TurboModuleRecord = { name: args.name, @@ -154,12 +199,18 @@ export function recordTurboModuleCall(args: { try { observer(record); } catch { - // one misbehaving observer must not drop records for others + // A misbehaving observer must not drop records for others. } } } } +/** + * Subscribes to per-record notifications. Fires for records that survive the + * `setAggregateRecordingEnabled` / `setIgnoredTurboModules` filters — the same + * set that reaches the aggregate map. Observers run synchronously on the wrap + * hot path, so must be O(1); thrown errors are swallowed. + */ export function addTurboModuleRecordObserver(observer: TurboModuleRecordObserver): void { observers.add(observer); } @@ -168,8 +219,16 @@ export function removeTurboModuleRecordObserver(observer: TurboModuleRecordObser observers.delete(observer); } -// Returns the `recordId` even for ignored modules so the wrap layer never has -// to branch — the paired record will be filtered out downstream. +/** + * Notifies start observers that a TurboModule call is about to run and returns + * a `recordId` correlator. The paired {@link recordTurboModuleCall} passes the + * same id back so consumers (e.g. per-span attribution) can associate the + * settle-time record with state captured at call-start time — matters for + * async calls that outlive the span they started in. + * + * Returns the `recordId` even for ignored modules so the wrap layer never has + * to branch — the paired record for an ignored module will be filtered out. + */ export function notifyTurboModuleCallStart(name: string, method: string, kind: TurboModuleCallKind): number { const recordId = nextRecordId++; if (ignoredModules.has(name) || startObservers.size === 0) { @@ -180,7 +239,7 @@ export function notifyTurboModuleCallStart(name: string, method: string, kind: T try { observer(event); } catch { - // one misbehaving observer must not drop the signal for others + // A misbehaving observer must not drop the start signal for others. } } return recordId; @@ -194,12 +253,26 @@ export function removeTurboModuleCallStartObserver(observer: TurboModuleCallStar startObservers.delete(observer); } -// Fires once when the aggregator transitions empty -> non-empty. Used to lazily -// arm the periodic flush so idle sessions don't churn timers. +/** + * 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; } +/** + * Master switch for the aggregator. When disabled, `recordTurboModuleCall` + * short-circuits and existing entries are cleared, so wrapped TurboModule + * calls can't accumulate into a map that nothing ever drains (e.g. when the + * integration was constructed with `enableAggregateStats: false`). + * + * Default: enabled. + */ export function setAggregateRecordingEnabled(enabled: boolean): void { recordingEnabled = enabled; if (!enabled) { @@ -207,6 +280,13 @@ export function setAggregateRecordingEnabled(enabled: boolean): void { } } +/** + * 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 []; @@ -228,11 +308,17 @@ export function drainTurboModuleAggregate(): TurboModuleAggregate[] { 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; } -/** Tests only. */ +/** + * Resets the aggregator. Tests only. + */ export function _resetTurboModuleAggregator(): void { aggregates.clear(); ignoredModules.clear(); diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 217f75ba98..a2ae9afa2c 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -5,7 +5,10 @@ import type { TurboModuleCallKind } from './turboModuleTracker'; import { notifyTurboModuleCallStart, recordTurboModuleCall } from './turboModuleAggregator'; import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; -// Tracked off-module so sealed proxies (that can't accept a marker property) are protected from double-wrapping. +/** + * Modules we've already wrapped. Tracked off-module so that even sealed proxies + * (which can't accept a marker property) are protected from double-wrapping. + */ let wrappedModules = new WeakSet(); /** Tests only. */ @@ -13,6 +16,19 @@ export function _resetWrappedModules(): void { wrappedModules = new WeakSet(); } +/** + * Wraps every function-valued property on the given TurboModule so that each + * invocation is recorded on the Sentry TurboModule tracker. Returns the same + * `module` reference for chaining convenience. + * + * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. + * - Async methods (those returning a thenable) are relabelled to `kind: 'async'` + * right after the call dispatches and popped when the returned promise settles. + * + * `skip` can be used to opt specific method names out of tracking (e.g. very + * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter + * stubs which would otherwise pollute the scope). + */ export function wrapTurboModule( name: string, module: T | null | undefined, @@ -30,7 +46,8 @@ export function wrapTurboModule( const methodNames = collectMethodNames(module); if (methodNames.length === 0) { - // Do NOT mark as wrapped — a later call (once a JSI HostObject materialises its methods) should retry. + // Do NOT add to wrappedModules — a later call (e.g. once a JSI HostObject + // has materialised its methods) should still get a chance to wrap. logger.warn( `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, @@ -44,8 +61,10 @@ export function wrapTurboModule( if (skip.has(key)) { continue; } - // JSI HostObject proxies may expose methods as accessors — guard so a throwing getter - // is treated as non-wrappable instead of aborting the whole loop. + // `target[key]` may be a getter (some JSI HostObject proxies expose methods + // as accessors under the New Architecture). Guard the read so a throwing + // getter is treated like a non-wrappable property instead of aborting the + // entire wrap loop. let original: unknown; try { original = target[key]; @@ -58,7 +77,12 @@ export function wrapTurboModule( const originalFn = original as (...a: unknown[]) => unknown; const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { - // Start optimistic as sync; relabel to 'async' if the return value proves thenable. + // The instrumentation must never break the user's call — every tracker + // interaction is isolated so a failure inside Sentry only drops the + // attribution data, never the real TurboModule invocation. + // + // 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(); let recordId: number | undefined; @@ -107,14 +131,21 @@ export function wrapTurboModule( target[key] = wrapper; wrappedAny = true; } catch { - // sealed / non-writable — skip this method, keep wrapping the rest + // Sealed / non-writable property — can't intercept this method, but we + // can still wrap the rest. Skip silently. } } + // Only mark as wrapped if we actually installed at least one wrapper. + // Otherwise a future call (e.g. after the proxy has materialised methods) + // should be allowed to retry. if (wrappedAny) { wrappedModules.add(module); } else { - // Methods found but none writable — surface so the silent no-op is debuggable. + // We discovered methods but couldn't intercept any of them — e.g. the + // module is frozen, or every method is a read-only accessor. Surface this + // so the silent no-op is debuggable (the issue would otherwise look like + // "no crash context attached" with no obvious cause). logger.warn( `[TurboModuleTracker] '${name}' has methods but none could be wrapped — TurboModule context will not be attached. ` + `This usually means the module is frozen or its methods are non-writable accessors.`, @@ -124,8 +155,13 @@ export function wrapTurboModule( return module; } -// Walks the proto chain — JSI HostObject-backed TurboModule proxies expose methods -// via the proto rather than as own enumerable properties. +/** + * Returns the union of own + prototype-chain method names on `module`, + * deduplicated and skipping `Object.prototype`. Walking the prototype chain is + * necessary for JSI HostObject-backed TurboModule proxies under RN's New + * Architecture, which can expose methods via the proto chain rather than as + * own enumerable properties. + */ function collectMethodNames(module: object): string[] { const seen = new Set(); let current: object | null = module; @@ -189,7 +225,8 @@ function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; } - // A user-defined `then` getter could throw — don't let that leak the tracker frame. + // A user-defined `then` getter could throw — don't let that escape into the + // wrapper (which would leak the in-flight tracker frame on top of the bug). try { const then = (value as { then?: unknown }).then; return typeof then === 'function';