Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module.<name>.<method>.{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`.

- Extend TurboModule instrumentation to the Old Architecture `NativeModules` bridge ([#6504](https://github.com/getsentry/sentry-react-native/pull/6504))

Apps still on the Old Architecture now flow legacy `NativeModules.*` calls through the same aggregator, span-attribution, and slow-call breadcrumb path as TurboModules on the New Architecture. Records carry `arch: 'new' | 'legacy'` (also surfaced on the flushed aggregate span and each attributed span) so analyses can split the signal by architecture. Lazily-exposed modules stay lazy β€” they're wrapped on first access instead of being initialised during `Sentry.init`. `RNSentry` and hot RN infrastructure (`Timing`, `UIManager`, animated modules) are skipped by default. Auto-wrap is on by default; opt out with `enableLegacyNativeModules: false`, or scope it via `legacyModulesSkip` / `legacyModulesSkipMethods` on `turboModuleContextIntegration`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I would advocate toward shortening this and skipping some of the implementation details


### Changes

- Expose `instrumentStateGraph` for manual LangGraph instrumentation ([#6520](https://github.com/getsentry/sentry-react-native/pull/6520))
Expand Down
7 changes: 7 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,9 @@ export class TouchEventBoundary extends React_2.Component<TouchEventBoundaryProp

export { TransactionEvent }

// @public
export type TurboModuleArch = 'new' | 'legacy';

// @public
export interface TurboModuleCall {
callId: number;
Expand All @@ -875,8 +878,11 @@ export const turboModuleContextIntegration: (options?: TurboModuleContextOptions
export interface TurboModuleContextOptions {
aggregateFlushIntervalMs?: number;
enableAggregateStats?: boolean;
enableLegacyNativeModules?: boolean;
enableSpanAttribution?: boolean;
ignoreTurboModules?: ReadonlyArray<string>;
legacyModulesSkip?: ReadonlyArray<string>;
legacyModulesSkipMethods?: Readonly<Record<string, ReadonlyArray<string>>>;
maxTopModulesPerSpan?: number;
modules?: Array<{
name: string;
Expand Down Expand Up @@ -936,6 +942,7 @@ export function wrapExpoRouterErrorBoundary<P extends ExpoRouterErrorBoundaryPro
// @public
export function wrapTurboModule<T extends object>(name: string, module: T | null | undefined, options?: {
skip?: ReadonlyArray<string>;
arch?: TurboModuleArch;
}): T | null | undefined;

// Warnings were encountered during analysis:
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,4 @@ export {
pushTurboModuleCall,
wrapTurboModule,
} from './turbomodule';
export type { TurboModuleCall, TurboModuleCallKind } from './turbomodule';
export type { TurboModuleArch, TurboModuleCall, TurboModuleCallKind } from './turbomodule';
40 changes: 36 additions & 4 deletions packages/core/src/js/integrations/turboModuleContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
setOnFirstTurboModuleRecord,
type TurboModuleCallStart,
type TurboModuleRecord,
wrapAllNativeModules,
wrapTurboModule,
} from '../turbomodule';
import { isTurboModuleEnabled } from '../utils/environment';
import { isRootSpan } from '../utils/span';
import { getRNSentryModule } from '../wrapper';
import {
Expand Down Expand Up @@ -71,6 +73,24 @@ export interface TurboModuleContextOptions {

/** Cap on per-`(module, method)` rows attributed to a single span. Default: `16`. */
maxTopModulesPerSpan?: number;

/**
* On Old Architecture, auto-wrap registered `NativeModules.*`. Default: `true`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

q: Is there any risk in defaulting to true? Would it make sense to start with false and gather feedback?

*
* Lazily-exposed modules stay lazy β€” they are wrapped on first access rather than
* initialised during `Sentry.init`.
*/
enableLegacyNativeModules?: boolean;

/**
* Additional modules to skip in the legacy auto-wrap. `RNSentry` and hot React
* Native infrastructure (`Timing`, `UIManager`, and the animated modules) are
* always skipped; pass them via `modules` to instrument them deliberately.
*/
legacyModulesSkip?: ReadonlyArray<string>;

/** Per-module method skips for the legacy auto-wrap. */
legacyModulesSkipMethods?: Readonly<Record<string, ReadonlyArray<string>>>;
}

// Scope-sync methods must NOT be tracked β€” `enableSyncToNative` calls them on
Expand Down Expand Up @@ -101,6 +121,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions
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;
const contextArch: 'new' | 'legacy' = isTurboModuleEnabled() ? 'new' : 'legacy';

let pendingFlushHandle: ReturnType<typeof setTimeout> | undefined;
let closed = false;
Expand All @@ -120,10 +141,19 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions
return {
name: INTEGRATION_NAME,
setupOnce() {
wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP });
// Wrap RNSentry first with its curated skip list; `wrapAllNativeModules`
// then skips it implicitly to avoid double-wrapping with a wider surface.
wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP, arch: contextArch });

for (const entry of options.modules ?? []) {
wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods });
wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods, arch: contextArch });
}

if (options.enableLegacyNativeModules !== false) {
wrapAllNativeModules({
skipModules: options.legacyModulesSkip,
skipMethodsPerModule: options.legacyModulesSkipMethods,
});
}

setAggregateRecordingEnabled(enableAggregate);
Expand Down Expand Up @@ -175,7 +205,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions
for (const window of windows) {
recordIntoWindow(window, record);
if (window.closed) {
attachWindowToSpan(window.span, window, maxTopModulesPerSpan, pendingSpanAttributes);
attachWindowToSpan(window.span, window, maxTopModulesPerSpan, contextArch, pendingSpanAttributes);
}
}
}
Expand Down Expand Up @@ -229,7 +259,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions
openWindowList.splice(idx, 1);
}
window.closed = true;
attachWindowToSpan(span, window, maxTopModulesPerSpan, pendingSpanAttributes);
attachWindowToSpan(span, window, maxTopModulesPerSpan, contextArch, pendingSpanAttributes);
});
}

Expand Down Expand Up @@ -344,6 +374,7 @@ function attachWindowToSpan(
span: Span,
window: WindowState,
topN: number,
arch: 'new' | 'legacy',
pendingSpanAttributes: Map<string, Record<string, number | string | undefined>>,
): void {
if (window.counters.size === 0) {
Expand All @@ -369,6 +400,7 @@ function attachWindowToSpan(
'turbo_module.total_error_count': totalErrorCount,
'turbo_module.total_duration_ms': roundMs(totalDurationMs),
'turbo_module.unique_methods': rows.length,
'turbo_module.arch': arch,
};
const top = rows[0];
if (top) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ function serialiseRows(rows: ReadonlyArray<TurboModuleAggregate>): Record<string
out[`${prefix}.error_count`] = row.errorCount;
out[`${prefix}.total_ms`] = roundMs(row.totalDurationMs);
out[`${prefix}.max_ms`] = roundMs(row.maxDurationMs);
out[`${prefix}.arch`] = row.arch;
for (let i = 0; i < row.buckets.length; i++) {
const label = HISTOGRAM_BUCKET_LABELS[i];
const count = row.buckets[i];
Expand All @@ -144,6 +145,7 @@ function serialiseRowAsObject(row: TurboModuleAggregate): {
name: string;
method: string;
kind: string;
arch: string;
call_count: number;
error_count: number;
total_ms: number;
Expand All @@ -162,6 +164,7 @@ function serialiseRowAsObject(row: TurboModuleAggregate): {
name: row.name,
method: row.method,
kind: row.kind,
arch: row.arch,
call_count: row.callCount,
error_count: row.errorCount,
total_ms: roundMs(row.totalDurationMs),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/turbomodule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ export {
} from './turboModuleAggregator';
export type {
TurboModuleAggregate,
TurboModuleArch,
TurboModuleCallStart,
TurboModuleCallStartObserver,
TurboModuleRecord,
TurboModuleRecordObserver,
} from './turboModuleAggregator';
export { wrapAllNativeModules } from './wrapNativeModules';
export type { WrapNativeModulesOptions } from './wrapNativeModules';
export { wrapTurboModule } from './wrapTurboModule';
39 changes: 33 additions & 6 deletions packages/core/src/js/turbomodule/turboModuleAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@

import type { TurboModuleCallKind } from './turboModuleTracker';

/**
* The React Native architecture the wrapped module lives under.
*
* `'new'` β€” TurboModule (New Architecture). Records come from `wrapTurboModule`
* against `TurboModuleRegistry`-resolved modules.
*
* `'legacy'` β€” bridge NativeModule (Old Architecture). Records come from the
* legacy wrapper (`wrapNativeModules`) which mirrors the same instrumentation
* surface on top of `NativeModules.*`.
*/
export type TurboModuleArch = 'new' | 'legacy';

/** Upper-exclusive bucket boundaries in milliseconds, matching the issue. */
export const HISTOGRAM_BUCKETS_MS: readonly number[] = [1, 5, 20, 100, 500];

Expand All @@ -26,7 +38,7 @@ export const HISTOGRAM_BUCKET_LABELS: readonly string[] = [
];

/**
* Aggregate counters for a single `(module, method, kind)` triplet.
* Aggregate counters for a single `(module, method, kind, arch)` tuple.
*/
export interface TurboModuleAggregate {
/** TurboModule name, e.g. `RNSentry`. */
Expand All @@ -35,6 +47,8 @@ export interface TurboModuleAggregate {
method: string;
/** Whether the invocation was `sync` (blocking) or `async` (returns a Promise). */
kind: TurboModuleCallKind;
/** Which architecture the module lives under. */
arch: TurboModuleArch;
/** Number of calls recorded since the last drain. */
callCount: number;
/** Number of calls that threw / rejected since the last drain. */
Expand All @@ -58,6 +72,7 @@ export interface TurboModuleRecord {
name: string;
method: string;
kind: TurboModuleCallKind;
arch: TurboModuleArch;
durationMs: number;
errored: boolean;
/**
Expand All @@ -73,6 +88,7 @@ export interface TurboModuleCallStart {
name: string;
method: string;
kind: TurboModuleCallKind;
arch: TurboModuleArch;
}

export type TurboModuleRecordObserver = (record: TurboModuleRecord) => void;
Expand All @@ -89,8 +105,8 @@ let nextRecordId = 0;
// don't accumulate into a map that nothing ever drains.
let recordingEnabled = true;

function makeKey(name: string, method: string, kind: TurboModuleCallKind): string {
return `${name}|${method}|${kind}`;
function makeKey(name: string, method: string, kind: TurboModuleCallKind, arch: TurboModuleArch): string {
return `${name}|${method}|${kind}|${arch}`;
}

function bucketIndexForDuration(durationMs: number): number {
Expand Down Expand Up @@ -137,23 +153,27 @@ export function recordTurboModuleCall(args: {
durationMs: number;
errored: boolean;
recordId?: number;
/** Defaults to `'new'` for backward compatibility with the pre-legacy callers. */
arch?: TurboModuleArch;
}): void {
if (ignoredModules.has(args.name)) {
return;
}

const arch: TurboModuleArch = args.arch ?? 'new';
const duration = args.durationMs > 0 ? args.durationMs : 0;

if (recordingEnabled) {
const wasEmpty = aggregates.size === 0;
const key = makeKey(args.name, args.method, args.kind);
const key = makeKey(args.name, args.method, args.kind, arch);

let entry = aggregates.get(key);
if (!entry) {
entry = {
name: args.name,
method: args.method,
kind: args.kind,
arch,
callCount: 0,
errorCount: 0,
totalDurationMs: 0,
Expand Down Expand Up @@ -191,6 +211,7 @@ export function recordTurboModuleCall(args: {
name: args.name,
method: args.method,
kind: args.kind,
arch,
durationMs: duration,
errored: args.errored,
recordId: args.recordId,
Expand Down Expand Up @@ -229,12 +250,17 @@ export function removeTurboModuleRecordObserver(observer: TurboModuleRecordObser
* 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 {
export function notifyTurboModuleCallStart(
name: string,
method: string,
kind: TurboModuleCallKind,
arch: TurboModuleArch = 'new',
): number {
const recordId = nextRecordId++;
if (ignoredModules.has(name) || startObservers.size === 0) {
return recordId;
}
const event: TurboModuleCallStart = { recordId, name, method, kind };
const event: TurboModuleCallStart = { recordId, name, method, kind, arch };
for (const observer of startObservers) {
try {
observer(event);
Expand Down Expand Up @@ -297,6 +323,7 @@ export function drainTurboModuleAggregate(): TurboModuleAggregate[] {
name: entry.name,
method: entry.method,
kind: entry.kind,
arch: entry.arch,
callCount: entry.callCount,
errorCount: entry.errorCount,
totalDurationMs: entry.totalDurationMs,
Expand Down
Loading
Loading