@sentry/react-native
Version:
Official Sentry SDK for react-native
290 lines • 13.1 kB
JavaScript
import { debug } from '@sentry/core';
import { createSpanJSON } from '../tracing/utils';
import { drainTurboModuleAggregate, HISTOGRAM_BUCKET_LABELS, hasTurboModuleAggregateData, setAggregateRecordingEnabled, setIgnoredTurboModules, setOnFirstTurboModuleRecord, wrapTurboModule, } from '../turbomodule';
import { getRNSentryModule } from '../wrapper';
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';
/** Origin string set on the aggregate span so it shows up as auto-instrumented. */
export const TURBO_MODULES_AGGREGATE_ORIGIN = 'auto.tracing.turbo_modules';
/** Default flush cadence for the periodic timer, in milliseconds. */
export const DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS = 30000;
/**
* 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;
// 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` -> ... .
const RNSENTRY_SKIP = [
'addListener',
'removeListeners',
'setContext',
'setTag',
'setExtra',
'setUser',
'addBreadcrumb',
'clearBreadcrumbs',
'setAttribute',
'setAttributes',
'removeAttribute',
];
/**
* 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.
*/
export const turboModuleContextIntegration = (options = {}) => {
var _a;
const enableAggregate = options.enableAggregateStats !== false;
const flushIntervalMs = (_a = options.aggregateFlushIntervalMs) !== null && _a !== void 0 ? _a : DEFAULT_AGGREGATE_FLUSH_INTERVAL_MS;
let pendingFlushHandle;
let closed = false;
return {
name: INTEGRATION_NAME,
setupOnce() {
var _a, _b;
// 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 (_a = options.modules) !== null && _a !== void 0 ? _a : []) {
wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods });
}
setAggregateRecordingEnabled(enableAggregate);
if (enableAggregate) {
setIgnoredTurboModules((_b = options.ignoreTurboModules) !== null && _b !== void 0 ? _b : ['RNSentry']);
}
},
setup(client) {
var _a;
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) {
setOnFirstTurboModuleRecord(() => {
if (closed || pendingFlushHandle !== undefined) {
return;
}
pendingFlushHandle = setTimeout(() => {
pendingFlushHandle = undefined;
flushPeriodicAggregate(client);
}, flushIntervalMs);
});
}
(_a = client.on) === null || _a === void 0 ? void 0 : _a.call(client, 'close', () => {
closed = true;
setOnFirstTurboModuleRecord(undefined);
if (pendingFlushHandle !== undefined) {
clearTimeout(pendingFlushHandle);
pendingFlushHandle = undefined;
}
});
},
processEvent(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.
stripEmptySentinelTags(event);
if (!enableAggregate || event.type !== 'transaction') {
return event;
}
if (!hasTurboModuleAggregateData()) {
return event;
}
attachAggregateToTransactionEvent(event);
return event;
},
};
};
function stripEmptySentinelTags(event) {
const tags = event.tags;
if (!tags) {
return;
}
if (tags['turbo_module.name'] === '') {
delete tags['turbo_module.name'];
}
if (tags['turbo_module.method'] === '') {
delete tags['turbo_module.method'];
}
}
/**
* 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) {
var _a, _b, _c;
const trace = (_a = event.contexts) === null || _a === void 0 ? void 0 : _a.trace;
if (!(trace === null || trace === void 0 ? void 0 : 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: Object.assign({ '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 = (_b = event.spans) !== null && _b !== void 0 ? _b : [];
event.spans.push(aggregateSpan);
event.measurements = (_c = event.measurements) !== null && _c !== void 0 ? _c : {};
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.
*/
function flushPeriodicAggregate(client) {
var _a;
if (!hasTurboModuleAggregateData()) {
return;
}
const snapshot = drainTurboModuleAggregate();
const totals = summarise(snapshot);
const topByTotalMs = [...snapshot].sort((a, b) => b.totalDurationMs - a.totalDurationMs);
(_a = client.captureEvent) === null || _a === void 0 ? void 0 : _a.call(client, {
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) {
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) {
const out = {};
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) {
const histogram = {};
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,
};
}
function roundMs(value) {
// Two-decimal precision is more than enough for human-readable totals
// and keeps the JSON payload terse.
return Math.round(value * 100) / 100;
}
//# sourceMappingURL=turboModuleContext.js.map