Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@

## Unreleased

### Features

- 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.<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 ([#6166](https://github.com/getsentry/sentry-react-native/issues/6166))

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. Auto-wrap is on by default; opt out with `enableLegacyNativeModules: false`, or scope it via `legacyModulesSkip` / `legacyModulesSkipMethods` on `turboModuleContextIntegration`.

### Fixes

- Make `copySentryJsonConfiguration` and the `*_SentryUpload` Gradle tasks compatible with the Gradle Configuration Cache ([#6469](https://github.com/getsentry/sentry-react-native/pull/6469))
Expand Down
3 changes: 3 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -872,12 +872,15 @@ export const turboModuleContextIntegration: (options?: TurboModuleContextOptions
export interface TurboModuleContextOptions {
aggregateFlushIntervalMs?: number;
enableAggregateStats?: boolean;
enableSpanAttribution?: boolean;
ignoreTurboModules?: ReadonlyArray<string>;
maxTopModulesPerSpan?: number;
modules?: Array<{
name: string;
module: object | null | undefined;
skipMethods?: ReadonlyArray<string>;
}>;
slowCallThresholdMs?: number;
}

// @public (undocumented)
Expand Down
608 changes: 339 additions & 269 deletions packages/core/src/js/integrations/turboModuleContext.ts

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions packages/core/src/js/integrations/turboModuleContextFlush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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';

export const TURBO_MODULES_AGGREGATE_OP = 'turbo_modules.aggregate';
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.
*/
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.`,
);
}
}

/** Custom Sentry event so long-running sessions without a transaction still emit a signal. */
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<TurboModuleAggregate>): {
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 };
}

function serialiseRows(rows: ReadonlyArray<TurboModuleAggregate>): Record<string, number | string> {
const out: Record<string, number | string> = {};
for (const row of rows) {
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);
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];
if (label !== undefined && count !== undefined) {
out[`${prefix}.${label}`] = count;
}
}
}
return out;
}

function serialiseRowAsObject(row: TurboModuleAggregate): {
name: string;
method: string;
kind: string;
arch: string;
call_count: number;
error_count: number;
total_ms: number;
max_ms: number;
histogram: Record<string, number>;
} {
const histogram: Record<string, number> = {};
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,
arch: row.arch,
call_count: row.callCount,
error_count: row.errorCount,
total_ms: roundMs(row.totalDurationMs),
max_ms: roundMs(row.maxDurationMs),
histogram,
};
}

export function roundMs(value: number): number {
return Math.round(value * 100) / 100;
}

function safeKeyPart(s: string): string {
return s.replace(/\./g, '_');
}
16 changes: 15 additions & 1 deletion packages/core/src/js/turbomodule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,28 @@ 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 } 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';
Loading
Loading