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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#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`.
Comment thread
alwx marked this conversation as resolved.

### Dependencies

- Bump Android SDK from v8.49.0 to v8.50.1 ([#6503](https://github.com/getsentry/sentry-react-native/pull/6503))
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
579 changes: 315 additions & 264 deletions packages/core/src/js/integrations/turboModuleContext.ts

Large diffs are not rendered by default.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aggregate attribute keys renamed

Medium Severity

Existing aggregate span attributes now run module/method names through safeKeyPart, so any TurboModule name or method containing _ or . gets a different key than before (for example ___). That silently changes the already-shipped turbo_modules.* telemetry shape without a changelog or migration note.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 6b8c23a. Configure here.

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<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,
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;
}

/**
* `.` 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, '_');
}
13 changes: 12 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,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 } from './turboModuleAggregator';
export type {
TurboModuleAggregate,
TurboModuleCallStart,
TurboModuleCallStartObserver,
TurboModuleRecord,
TurboModuleRecordObserver,
} from './turboModuleAggregator';
export { wrapTurboModule } from './wrapTurboModule';
Loading
Loading