UNPKG

@sentry/react-native

Version:
184 lines 6.33 kB
/** * Per-(module, method, kind) aggregation of TurboModule invocations. * * The wrap layer in `wrapTurboModule` already measures each call's duration * and outcome. Sending one span per call would explode span counts on hot * async paths (every `RNSentry.captureEnvelope`, every JSI lookup, …) so * instead we keep O(1) per-key counters + a fixed-bucket histogram and flush * the aggregate at coarse-grained points (transaction finish, periodic timer). * * See https://github.com/getsentry/sentry-react-native/issues/6164. */ /** Upper-exclusive bucket boundaries in milliseconds, matching the issue. */ export const HISTOGRAM_BUCKETS_MS = [1, 5, 20, 100, 500]; /** Suffixes used when serialising bucket counts (e.g. as span attributes). */ export const HISTOGRAM_BUCKET_LABELS = [ 'lt_1ms', 'lt_5ms', 'lt_20ms', 'lt_100ms', 'lt_500ms', 'gte_500ms', ]; const aggregates = new Map(); const ignoredModules = new Set(); let onFirstRecordAfterEmpty; // When `false`, `recordTurboModuleCall` is a no-op. The integration flips // this off when `enableAggregateStats: false` so wrapped TurboModule calls // don't accumulate into a map that nothing ever drains. let recordingEnabled = true; function makeKey(name, method, kind) { return `${name}|${method}|${kind}`; } function bucketIndexForDuration(durationMs) { var _a; for (let i = 0; i < HISTOGRAM_BUCKETS_MS.length; i++) { // `i` is bounded by `.length`, so the read is in range — `?? Infinity` // is a noop at runtime but satisfies `noUncheckedIndexedAccess`. const boundary = (_a = HISTOGRAM_BUCKETS_MS[i]) !== null && _a !== void 0 ? _a : Infinity; if (durationMs < boundary) { return i; } } return HISTOGRAM_BUCKETS_MS.length; } /** * Replaces the set of TurboModule names whose calls should NOT be aggregated. * * Per the issue, users may want to opt-out specific modules (e.g. `RNSentry` * itself, to keep the signal clean of SDK overhead). An empty list (default) * means every wrapped module is aggregated. */ export function setIgnoredTurboModules(names) { ignoredModules.clear(); if (!names) { return; } for (const name of names) { ignoredModules.add(name); } } /** * Records a single TurboModule method invocation into the aggregate. * * Must be O(1): called on every wrapped method invocation, including hot * async paths. Negative durations (a clock skew artefact between push/pop) * are clamped to zero so they still increment counters but don't poison * totals or buckets. */ export function recordTurboModuleCall(args) { var _a; if (!recordingEnabled) { return; } if (ignoredModules.has(args.name)) { return; } const wasEmpty = aggregates.size === 0; const duration = args.durationMs > 0 ? args.durationMs : 0; const key = makeKey(args.name, args.method, args.kind); let entry = aggregates.get(key); if (!entry) { entry = { name: args.name, method: args.method, kind: args.kind, callCount: 0, errorCount: 0, totalDurationMs: 0, maxDurationMs: 0, buckets: new Array(HISTOGRAM_BUCKETS_MS.length + 1).fill(0), }; aggregates.set(key, entry); } entry.callCount += 1; if (args.errored) { entry.errorCount += 1; } entry.totalDurationMs += duration; if (duration > entry.maxDurationMs) { entry.maxDurationMs = duration; } const bucket = bucketIndexForDuration(duration); // Bucket index is bounded by `bucketIndexForDuration`; `?? 0` here only // exists to satisfy `noUncheckedIndexedAccess`. entry.buckets[bucket] = ((_a = entry.buckets[bucket]) !== null && _a !== void 0 ? _a : 0) + 1; if (wasEmpty && onFirstRecordAfterEmpty) { // Don't let a misbehaving observer corrupt the aggregate. try { onFirstRecordAfterEmpty(); } catch (_b) { // intentionally swallowed } } } /** * Registers a callback fired exactly once when the aggregator transitions * from empty to non-empty — i.e. when the first record after a drain (or * after init) lands. The integration uses this to lazily schedule a periodic * flush only when there's work to do, so idle sessions don't churn timers. * * Pass `undefined` to unregister. */ export function setOnFirstTurboModuleRecord(cb) { onFirstRecordAfterEmpty = cb; } /** * Master switch for the aggregator. When disabled, `recordTurboModuleCall` * short-circuits and existing entries are cleared, so wrapped TurboModule * calls can't accumulate into a map that nothing ever drains (e.g. when the * integration was constructed with `enableAggregateStats: false`). * * Default: enabled. */ export function setAggregateRecordingEnabled(enabled) { recordingEnabled = enabled; if (!enabled) { aggregates.clear(); } } /** * Drains and returns the current aggregate, clearing the internal state. * * The returned array is a shallow copy: callers may freely mutate it (e.g. * to slice top-N) without affecting the next interval. `buckets` arrays on * each entry are also new instances. */ export function drainTurboModuleAggregate() { if (aggregates.size === 0) { return []; } const out = []; for (const entry of aggregates.values()) { out.push({ name: entry.name, method: entry.method, kind: entry.kind, callCount: entry.callCount, errorCount: entry.errorCount, totalDurationMs: entry.totalDurationMs, maxDurationMs: entry.maxDurationMs, buckets: entry.buckets.slice(), }); } aggregates.clear(); return out; } /** * Returns whether the aggregator has anything to flush right now. Useful for * the periodic timer to skip a no-op send. */ export function hasTurboModuleAggregateData() { return aggregates.size > 0; } /** * Resets the aggregator. Tests only. */ export function _resetTurboModuleAggregator() { aggregates.clear(); ignoredModules.clear(); onFirstRecordAfterEmpty = undefined; recordingEnabled = true; } //# sourceMappingURL=turboModuleAggregator.js.map