@sentry/react-native
Version:
Official Sentry SDK for react-native
184 lines • 6.33 kB
JavaScript
/**
* 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