UNPKG

@sentry/react-native

Version:
290 lines 13.1 kB
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