Skip to content
Merged
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.

### Changes

- Expose `instrumentStateGraph` for manual LangGraph instrumentation ([#6520](https://github.com/getsentry/sentry-react-native/pull/6520))
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 @@ -875,12 +875,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}`;
Comment thread
cursor[bot] marked this conversation as resolved.
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