From ecb6c3d95fe7612fa68cdef39ca7f54b739927b6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 17 Aug 2026 17:41:17 +0200 Subject: [PATCH 1/2] feat(core): add opt-in loud invariants assertion instrumentation Add an opt-in Metro/Babel transform that rewrites assertion call sites (invariant, assert, warning, console.assert) so a violated assertion reports a non-fatal Sentry event instead of being stripped from release bundles or crashing with a minified message. Adds the captureInvariantViolation runtime API and fixes a related gap in the RN global error handler so an already-captured, re-thrown error is not reported a second time. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + packages/core/etc/sentry-react-native.api.md | 18 + packages/core/src/js/index.ts | 2 + .../integrations/reactnativeerrorhandlers.ts | 13 + packages/core/src/js/invariant.ts | 216 ++++++++ packages/core/src/js/tools/metroconfig.ts | 30 +- .../js/tools/sentryBabelTransformerUtils.ts | 30 ++ .../js/tools/sentryInvariantBabelPlugin.ts | 464 ++++++++++++++++++ .../reactnativeerrorhandlers.test.ts | 22 + packages/core/test/invariant.test.ts | 184 +++++++ .../test/tools/sentryBabelTransformer.test.ts | 83 ++++ .../tools/sentryInvariantBabelPlugin.test.ts | 225 +++++++++ samples/react-native/metro.config.js | 11 + .../react-native/src/Screens/ErrorsScreen.tsx | 51 ++ 14 files changed, 1352 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/js/invariant.ts create mode 100644 packages/core/src/js/tools/sentryInvariantBabelPlugin.ts create mode 100644 packages/core/test/invariant.test.ts create mode 100644 packages/core/test/tools/sentryInvariantBabelPlugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad19bf7ea..6fe6f8496a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add opt-in "Loud Invariants" Metro transform that reports violated `invariant`/`assert`/`warning`/`console.assert` assertions as non-fatal Sentry events instead of crashing or being stripped ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) + ## 8.23.0 ### Changes diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index e36c2d796d..10e36f0cd1 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -176,6 +176,9 @@ export { captureException } export { captureFeedback } +// @public +export function captureInvariantViolation(options?: InvariantViolationOptions): string; + export { captureMessage } // @public @@ -223,6 +226,9 @@ export const deeplinkIntegration: (...args: any[]) => Integration & { name: string; }; +// @public +export const DEFAULT_INVARIANT_MECHANISM = "invariant"; + // @public export const deviceContextIntegration: () => Integration; @@ -473,6 +479,18 @@ export { instrumentStateGraph } export { instrumentStateGraphCompile } +// @public (undocumented) +export interface InvariantViolationOptions { + condition?: string; + error?: Error; + message?: string; + once?: boolean; + pragma?: string; + rethrow?: boolean; + siteId?: string; + values?: Record; +} + export { LangChainIntegration } export { LangChainOptions } diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 6550c89cb2..9ac52af6a8 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -118,6 +118,8 @@ export { pauseAppHangTracking, resumeAppHangTracking, } from './sdk'; +export { captureInvariantViolation, DEFAULT_INVARIANT_MECHANISM } from './invariant'; +export type { InvariantViolationOptions } from './invariant'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { NavigationContainer } from './NavigationContainer'; export type { FontStyle, NavigationTheme, SentryNavigationContainerProps } from './NavigationContainer'; diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index d3ceef1440..d6b5d2e224 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -160,6 +160,19 @@ function setupErrorUtilsGlobalHandler(): void { // oxlint-disable-next-line typescript-eslint(no-explicit-any), typescript-eslint(no-unsafe-member-access) errorUtils.setGlobalHandler(async (error: any, isFatal?: boolean) => { + // Skip errors already captured by Sentry and then re-thrown (e.g. a "loud + // invariant" that reports a handled event before re-throwing, or user code + // doing `captureException(e); throw e;`). `client.captureException` dedups + // via this same `__sentry_captured__` flag, but this handler reports through + // `eventFromException` + `captureEvent`, which don't — so without this guard + // the error is reported a second time as an unhandled crash. Let the default + // handler still run (redbox in dev, teardown in prod). + // oxlint-disable-next-line typescript-eslint(no-unsafe-member-access) + if (error?.__sentry_captured__) { + defaultHandler(error, isFatal); + return; + } + // We want to handle fatals, but only in production mode. const shouldHandleFatal = isFatal && !__DEV__; if (shouldHandleFatal) { diff --git a/packages/core/src/js/invariant.ts b/packages/core/src/js/invariant.ts new file mode 100644 index 0000000000..0de80bf6d5 --- /dev/null +++ b/packages/core/src/js/invariant.ts @@ -0,0 +1,216 @@ +import { addNonEnumerableProperty, captureException, withScope } from '@sentry/core'; + +import { createSyntheticError, isErrorLike } from './utils/error'; + +/** + * Default mechanism type reported for a violated invariant. + * + * This is a de-facto (unregistered) mechanism type — Sentry ingestion accepts + * arbitrary `mechanism.type` values and converts them to a tag, so no backend + * or Relay registration is required. Violations render as non-fatal, handled + * events with a full stack trace, breadcrumbs, and Session Replay attached. + */ +export const DEFAULT_INVARIANT_MECHANISM = 'invariant'; + +export interface InvariantViolationOptions { + /** + * The source text of the assertion condition that failed, e.g. `"total >= 0"`. + * Surfaced under `mechanism.data.condition` and used to build the default message. + */ + condition?: string; + /** + * Runtime values that violated the invariant, e.g. `{ total: -4 }`. + * + * `mechanism.data` only accepts flat `string | boolean` values, so each entry + * is flattened to `values.` and stringified. The full object is also + * preserved as a JSON snapshot under `values`. + */ + values?: Record; + /** + * The assertion pragma that produced this violation (`invariant`, `assert`, + * `console.assert`, `warning`, ...). Becomes `mechanism.type`. + * + * @default 'invariant' + */ + pragma?: string; + /** + * Human-readable message. Defaults to `Invariant violated: `. + */ + message?: string; + /** + * An already-constructed error carrying the stack of the assertion call site. + * The Babel transform passes a bare `new Error()` created at the call site so + * the stack top is the assertion site itself (in dev and release); its + * message is backfilled from `message`/`condition`. When omitted a synthetic + * error is fabricated so a stack is captured without actually throwing. + */ + error?: Error; + /** + * A stable identifier for the call site (e.g. `"ErrorsScreen.tsx:73:4"`), + * injected by the Babel transform. When provided, the violation is reported + * at most once per site per session to avoid flooding the issue stream from + * an assertion inside a hot loop or a frequently re-rendered component. + * + * Pass `once: false` to opt out and report on every invocation. + */ + siteId?: string; + /** + * Whether to deduplicate by `siteId`. Defaults to `true` when a `siteId` is + * provided. Has no effect without a `siteId`. + * + * @default true + */ + once?: boolean; + /** + * Re-throw the `error` after reporting, preserving the original throwing + * semantics of hard preconditions (`invariant`, `assert`). The Babel transform + * sets this for pragmas listed in its `rethrowPragmas`, so downstream code that + * relied on the assertion halting execution is not reached with invalid state. + * + * The re-throw fires even when the report is deduplicated by `siteId` — + * deduplication suppresses the duplicate *event*, never the control flow. To + * avoid the rethrown error being reported a second time as an unhandled crash, + * the reporter tags it so Sentry's global handler skips it. + * + * Report-only pragmas (`warning`, `console.assert`) leave this `false`. + * + * @default false + */ + rethrow?: boolean; +} + +/** + * Call sites already reported this session, keyed by `siteId`. Kept module-level + * so it persists for the lifetime of the JS runtime (i.e. the session). + */ +const reportedSites = new Set(); + +/** Max length of a single flattened `values.` string before truncation. */ +const MAX_VALUE_LENGTH = 256; +/** Max length of the whole `values` JSON snapshot before truncation. */ +const MAX_SNAPSHOT_LENGTH = 1024; + +/** Truncates `text` to `max` characters, appending an ellipsis marker if cut. */ +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…[truncated]` : text; +} + +/** + * Re-throws `error` after tagging it as already reported. The tag + * (`__sentry_captured__`) is the same non-enumerable marker `@sentry/core` + * stamps in `checkOrSetAlreadyCaught`, so both `captureException` and — once it + * honors the flag — React Native's ErrorUtils global handler skip it instead of + * reporting the re-thrown error a second time as an unhandled crash. Set on + * *every* rethrow path (including the dedup-suppressed branch, which never + * reaches the tail) so the guard can never be bypassed. + */ +function rethrowCaptured(error: Error): never { + addNonEnumerableProperty(error as unknown as Record, '__sentry_captured__', true); + throw error; +} + +/** + * Flattens a runtime values object into the flat `string | boolean` map that + * `mechanism.data` accepts. Nested/complex values are stringified. Both the + * per-key entries and the JSON snapshot are length-capped so a large captured + * object (e.g. a whole config or dimensions map) can't bloat the event payload. + */ +function flattenValues(values: Record): { [key: string]: string | boolean } { + const data: { [key: string]: string | boolean } = {}; + for (const key of Object.keys(values)) { + const value = values[key]; + data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(String(value), MAX_VALUE_LENGTH); + } + try { + data.values = truncate(JSON.stringify(values) ?? 'undefined', MAX_SNAPSHOT_LENGTH); + } catch (_e) { + // Circular or non-serializable values — the flattened entries above still apply. + } + return data; +} + +/** + * Reports a violated invariant to Sentry as a non-fatal (handled) event without + * throwing or crashing the app. + * + * This is the runtime target of the `@sentry/babel-plugin-invariant` transform: + * the plugin rewrites `invariant()` / `assert()` / `console.assert()` / + * `warning()` call sites so that a falsy condition invokes this reporter instead + * of being stripped from the release bundle. + * + * It can also be called by hand (Milestone 0) to de-risk the reporting path. + * + * @returns the id of the captured Sentry event. + */ +export function captureInvariantViolation(options: InvariantViolationOptions = {}): string { + const { condition, values, pragma = DEFAULT_INVARIANT_MECHANISM, siteId, once = true, rethrow = false } = options; + + const message = options.message ?? (condition ? `Invariant violated: ${condition}` : 'Invariant violated'); + + const error = options.error ?? new Error(message); + // The Babel transform creates a bare `new Error()` at the call site so its + // stack top is the assertion site; backfill the readable message here, in the + // one place that owns the default-message template. + if (!error.message) { + error.message = message; + } + + // Report each call site at most once per session unless the caller opts out. + // Deduplication only suppresses the duplicate *event* — for a throwing pragma + // the precondition is still violated, so control flow must still be halted. + if (siteId !== undefined && once && reportedSites.has(siteId)) { + if (rethrow) { + rethrowCaptured(error); + } + return ''; + } + if (siteId !== undefined && once) { + reportedSites.add(siteId); + } + + const data: { [key: string]: string | boolean } = {}; + if (condition !== undefined) { + data.condition = condition; + } + if (siteId !== undefined) { + data.siteId = siteId; + } + if (values !== undefined) { + Object.assign(data, flattenValues(values)); + } + + const eventId = withScope(scope => { + // Group deterministically by call site rather than by the runtime stack top. + // For an inline assertion the top frame is a generic host frame (e.g. React + // Native's Pressability internals) shared by every violation, so default + // stack-based grouping would collapse unrelated invariants into one issue. + // The build-time `siteId` is stable across dev and release; fall back to the + // condition (then message) for hand-written calls that carry no `siteId`. + scope.setFingerprint(['loud-invariant', pragma, siteId ?? condition ?? message]); + + return captureException(error, { + // `synthetic: true` — the error was fabricated to carry a stack, not thrown. + // `handled: true` — renders as a non-fatal in the issue stream. + mechanism: { + type: pragma, + handled: true, + synthetic: true, + data, + }, + // When the error carries no usable stack, attach a synthetic one so the + // event still has a stack trace pointing near the call site. + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + }); + }); + + if (rethrow) { + // Preserve the precondition's throwing semantics: re-throw after capturing so + // downstream code that assumed the precondition held is not reached with + // invalid state. `rethrowCaptured` tags the error so the global error handler + // skips it — otherwise the same violation is reported twice (once handled + // here, once as an unhandled crash). + rethrowCaptured(error); + } + + return eventId; +} diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 34464203fc..7dd5ecd452 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -6,6 +6,7 @@ import { debug } from '@sentry/core'; import * as process from 'process'; import { env } from 'process'; +import type { SentryInvariantBabelPluginOptions } from './sentryInvariantBabelPlugin'; import type { MetroCustomSerializer } from './utils'; import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; @@ -88,6 +89,19 @@ export interface SentryMetroConfigOptions { * @default false */ autoWrapExpoRouterErrorBoundary?: boolean; + /** + * Rewrite assertion call sites (`invariant`, `assert`, `warning`, + * `console.assert`) so a violated invariant is reported to Sentry as a + * non-fatal (handled) event instead of being stripped from the release + * bundle or crashing the app. + * + * Pass `true` to instrument first-party code with the default pragma set, or + * an object to customize the pragmas and to opt into instrumenting + * `node_modules`. + * + * @default false + */ + loudInvariants?: boolean | SentryInvariantBabelPluginOptions; } export interface SentryExpoConfigOptions { @@ -119,6 +133,7 @@ export function withSentryConfig( enableSourceContextInDevelopment = true, optionsFile = true, autoWrapExpoRouterErrorBoundary = false, + loudInvariants = false, }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -127,8 +142,13 @@ export function withSentryConfig( newConfig = withSentryDebugId(newConfig); newConfig = withSentryFramesCollapsed(newConfig); - if (annotateReactComponents || autoWrapExpoRouterErrorBoundary) { - newConfig = withSentryBabelTransformer(newConfig, annotateReactComponents, autoWrapExpoRouterErrorBoundary); + if (annotateReactComponents || autoWrapExpoRouterErrorBoundary || loudInvariants) { + newConfig = withSentryBabelTransformer( + newConfig, + annotateReactComponents, + autoWrapExpoRouterErrorBoundary, + loudInvariants, + ); } if (includeWebReplay === false) { newConfig = withSentryResolver(newConfig, includeWebReplay); @@ -170,11 +190,13 @@ export function getSentryExpoConfig( let newConfig = withSentryFramesCollapsed(config); const autoWrapExpoRouterErrorBoundary = options.autoWrapExpoRouterErrorBoundary ?? false; - if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary) { + const loudInvariants = options.loudInvariants ?? false; + if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary || loudInvariants) { newConfig = withSentryBabelTransformer( newConfig, options.annotateReactComponents ?? false, autoWrapExpoRouterErrorBoundary, + loudInvariants, ); } @@ -225,6 +247,7 @@ export function withSentryBabelTransformer( | boolean | { ignoredComponents?: string[]; autoInjectSentryLabel?: boolean; textComponentNames?: string[] }, autoWrapExpoRouterErrorBoundary: boolean = false, + loudInvariants: SentryMetroConfigOptions['loudInvariants'] = false, ): MetroConfig { const defaultBabelTransformerPath = config.transformer?.babelTransformerPath; debug.log('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath); @@ -247,6 +270,7 @@ export function withSentryBabelTransformer( ? { annotateReactComponents: typeof annotateReactComponents === 'object' ? annotateReactComponents : {} } : {}), autoWrapExpoRouterErrorBoundary, + ...(loudInvariants ? { loudInvariants: typeof loudInvariants === 'object' ? loudInvariants : {} } : {}), }); return { diff --git a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts index 5b39e5959d..57d8f4c56e 100644 --- a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts +++ b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts @@ -2,9 +2,11 @@ import componentAnnotatePlugin from '@sentry/bundler-plugins/babel-plugin'; import { debug } from '@sentry/core'; import * as process from 'process'; +import type { SentryInvariantBabelPluginOptions } from './sentryInvariantBabelPlugin'; import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; import sentryExpoRouterAutoWrapBabelPlugin from './sentryExpoRouterAutoWrapBabelPlugin'; +import sentryInvariantBabelPlugin from './sentryInvariantBabelPlugin'; export type SentryBabelTransformerOptions = { annotateReactComponents?: { @@ -13,6 +15,7 @@ export type SentryBabelTransformerOptions = { textComponentNames?: string[]; }; autoWrapExpoRouterErrorBoundary?: boolean; + loudInvariants?: SentryInvariantBabelPluginOptions; }; export const SENTRY_DEFAULT_BABEL_TRANSFORMER_PATH = 'SENTRY_DEFAULT_BABEL_TRANSFORMER_PATH'; @@ -106,6 +109,9 @@ export function createSentryBabelTransformer(): BabelTransformer { if (options?.autoWrapExpoRouterErrorBoundary) { addSentryExpoRouterAutoWrapPlugin(transformerArgs); } + if (options?.loudInvariants !== undefined) { + addSentryLoudInvariantsPlugin(transformerArgs, options.loudInvariants); + } return defaultTransformer.transform(...args); }; @@ -142,3 +148,27 @@ function addSentryExpoRouterAutoWrapPlugin(args: BabelTransformerArgs | undefine } args.plugins.push([sentryExpoRouterAutoWrapBabelPlugin, {}]); } + +function addSentryLoudInvariantsPlugin( + args: BabelTransformerArgs | undefined, + options: NonNullable, +): void { + if (!args || typeof args.filename !== 'string' || !Array.isArray(args.plugins)) { + return undefined; + } + // The plugin applies its own `includeNodeModules` and SDK-self-exclusion + // guards; this early return just avoids pushing the plugin for dependency + // files it would skip anyway. Mirror the plugin's `boolean | string[]` + // semantics: `false`/absent skips all node_modules, an array allowlists by + // path substring, `true` instruments all. + const inc = options.includeNodeModules; + if (args.filename.includes('node_modules')) { + if (!inc) { + return undefined; + } + if (Array.isArray(inc) && !inc.some(fragment => args.filename.includes(fragment))) { + return undefined; + } + } + args.plugins.push([sentryInvariantBabelPlugin, options]); +} diff --git a/packages/core/src/js/tools/sentryInvariantBabelPlugin.ts b/packages/core/src/js/tools/sentryInvariantBabelPlugin.ts new file mode 100644 index 0000000000..f693ee9ae7 --- /dev/null +++ b/packages/core/src/js/tools/sentryInvariantBabelPlugin.ts @@ -0,0 +1,464 @@ +import type { NodePath, PluginObj, PluginPass, types as BabelTypes } from '@babel/core'; + +/** + * Babel plugin that rewrites assertion call sites so a violated invariant is + * reported to Sentry as a non-fatal (handled) event instead of being stripped + * from the release bundle or crashing with a minified, unreadable message. + * + * It matches calls whose callee is one of the configured pragmas — by default + * `invariant`, `assert`, `warning` and `console.assert` — all of which fire on a + * **falsy** first argument. Each match: + * + * ```ts + * invariant(total >= 0, 'bad total'); + * ``` + * + * is rewritten to short-circuit on the condition and report only when it is + * falsy: + * + * ```ts + * var _captureInvariantViolation = require('@sentry/react-native').captureInvariantViolation; + * // ... + * total >= 0 || _captureInvariantViolation({ + * pragma: 'invariant', + * condition: 'total >= 0', + * values: { total: total }, + * message: 'bad total', + * siteId: 'index.tsx:1:0', + * rethrow: true, + * error: new Error(), + * }); + * ``` + * + * ## Preserving control flow (`rethrow`) + * + * `invariant`/`assert` are **hard preconditions**: downstream code relies on the + * throw having happened (`invariant(user); return user.name;`). Silently swapping + * the throw for a report would let execution continue past a violated + * precondition and turn a clean, localized failure into a confusing downstream + * crash or silent state corruption. So for the pragmas in `rethrowPragmas` + * (default `invariant`, `assert`) the plugin emits `rethrow: true` and the + * reporter re-throws after capturing — you gain the readable, grouped Sentry + * event *and* keep the original control flow. `warning`/`console.assert` never + * threw, so they stay report-only. + * + * ## Avoiding false positives (`requireResolvedImport`) + * + * Pragmas are matched by name only, so a function coincidentally named `assert` + * or `warning` with unrelated semantics would be miscompiled. To guard against + * this the plugin can require the callee to resolve to an `import`/`require` of a + * known assertion module (`assertionModules`). This is **on by default for + * `node_modules`** (where names collide across unknown packages) and **off for + * first-party code** (where you control the names). `console.assert` and other + * member pragmas are always allowed — `console` is a global. + * + * ## Runtime values + * + * `values` carries the live values of the identifiers in the condition so the + * issue explains *why* it failed; it is only evaluated when the condition is + * falsy (the right-hand side of `||`). + * + * ## Stack anchoring + * + * The `Error` is constructed at the call site (rather than inside the reporter) + * so its stack top is the assertion site itself — in dev and release alike — + * without depending on `error.framesToPop` (a dev-only debug-symbolicator knob) + * or the `in_app` path heuristic. The reporter backfills a readable message. + * + * ## Injection & scope + * + * The helper binding is injected once per file with a collision-free local name. + * It is a CommonJS `require` rather than an ESM `import` on purpose: with + * `includeNodeModules` the plugin runs over dependency files that may be plain + * CommonJS, and Metro's ESM→CJS transform bails on those before it would see an + * injected `import`, leaving a bare `import` that Hermes rejects at release-build + * time. A `require` binding works uniformly in both module kinds and is picked up + * by Metro's dependency collection. The transform is idempotent: the rewritten + * call's callee is the generated helper name, which never matches a pragma. + * + * Files inside `node_modules` are skipped unless `includeNodeModules` is set — + * either `true` (all dependencies) or an array of path substrings (an allowlist, + * so only the packages you name are instrumented). The Sentry SDK's own modules + * are always skipped, since instrumenting them would inject a self-referential + * `require('@sentry/react-native')` into the package that provides the reporter. + */ + +const SENTRY_PACKAGE = '@sentry/react-native'; +const CAPTURE_FN = 'captureInvariantViolation'; +/** Per-file state key holding the injected helper's local identifier. */ +const IMPORT_UID_KEY = 'sentryInvariantCaptureUid'; + +const DEFAULT_PRAGMAS = ['invariant', 'assert', 'warning', 'console.assert']; + +/** + * Pragmas that throw on a falsy condition. For these the reporter re-throws + * after capturing so the original precondition semantics are preserved. + */ +const DEFAULT_RETHROW_PRAGMAS = ['invariant', 'assert']; + +/** + * Module specifiers a bare pragma identifier must resolve to when import + * resolution is required (see `requireResolvedImport`). Matched exactly or by + * trailing path segment, so `fbjs/lib/invariant` matches `invariant`. + */ +const DEFAULT_ASSERTION_MODULES = ['invariant', 'tiny-invariant', 'warning', 'assert', 'node:assert']; + +/** + * Path fragments identifying the Sentry SDK's own source. Files matching any of + * these are never instrumented — the plugin injects a `require` of the SDK, so + * rewriting the SDK's own asserts would create a self-referential require. The + * second marker covers the monorepo dev symlink, whose path has no + * `node_modules/@sentry` segment. + */ +const SENTRY_SDK_PATH_MARKERS = ['/@sentry/', 'sentry-react-native/packages/']; + +export interface SentryInvariantBabelPluginOptions { + /** + * The assertion pragmas to rewrite. Simple identifiers (`invariant`) match a + * bare call; a dotted name (`console.assert`) matches a member call on that + * object. All configured pragmas are treated as "fire when the first argument + * is falsy". + * + * @default ['invariant', 'assert', 'warning', 'console.assert'] + */ + pragmas?: string[]; + /** + * Pragmas whose original semantics is to throw on a falsy condition. For a + * match on one of these, the reporter re-throws after capturing so control + * flow is preserved (a violated `invariant` still stops execution). Pragmas + * not listed here are report-only. + * + * @default ['invariant', 'assert'] + */ + rethrowPragmas?: string[]; + /** + * Also rewrite assertion call sites inside `node_modules`. Pass `true` to + * instrument all dependencies, or an array of path substrings to allowlist + * only specific packages (e.g. `['react-native/Libraries/Utilities']`). Off by + * default so only first-party code is instrumented. + * + * @default false + */ + includeNodeModules?: boolean | string[]; + /** + * Only rewrite a bare-identifier pragma when its callee resolves to an + * `import`/`require` of a module in `assertionModules`. Guards against + * miscompiling a coincidentally-named local function. Defaults to `true` for + * files under `node_modules` (unknown packages, colliding names) and `false` + * for first-party code. Member pragmas like `console.assert` are unaffected. + */ + requireResolvedImport?: boolean; + /** + * Module specifiers a bare pragma must resolve to when `requireResolvedImport` + * is in effect. + * + * @default ['invariant', 'tiny-invariant', 'warning', 'assert', 'node:assert'] + */ + assertionModules?: string[]; +} + +interface BabelApi { + types: typeof BabelTypes; +} + +/** + * Returns the pragma string matched by `callee`, or `undefined`. Supports bare + * identifiers (`invariant`) and single-level member expressions (`console.assert`). + */ +function matchPragma( + t: typeof BabelTypes, + callee: BabelTypes.Expression | BabelTypes.V8IntrinsicIdentifier, + pragmas: string[], +): string | undefined { + if (t.isIdentifier(callee)) { + return pragmas.includes(callee.name) ? callee.name : undefined; + } + if ( + t.isMemberExpression(callee) && + !callee.computed && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) + ) { + const dotted = `${callee.object.name}.${callee.property.name}`; + return pragmas.includes(dotted) ? dotted : undefined; + } + return undefined; +} + +/** True when `filename` belongs to the Sentry SDK's own source. */ +function isSentrySdkPath(filename: string): boolean { + return SENTRY_SDK_PATH_MARKERS.some(marker => filename.includes(marker)); +} + +/** True when `source` matches one of `modules` exactly or by trailing segment. */ +function moduleMatches(source: string, modules: string[]): boolean { + return modules.some(m => source === m || source.endsWith(`/${m}`)); +} + +/** + * True when the bare-identifier callee at `calleePath` resolves to an + * `import`/`require` of a module in `modules`. Handles `import invariant from + * 'invariant'`, named imports, and `const invariant = require('invariant')`. + */ +function calleeResolvesToAssertionModule( + t: typeof BabelTypes, + calleePath: NodePath, + modules: string[], +): boolean { + if (!calleePath.isIdentifier()) { + return false; + } + const binding = calleePath.scope.getBinding(calleePath.node.name); + if (!binding) { + return false; + } + const decl = binding.path; + if (decl.isImportDefaultSpecifier() || decl.isImportSpecifier() || decl.isImportNamespaceSpecifier()) { + const source = decl.parentPath?.isImportDeclaration() ? decl.parentPath.node.source.value : undefined; + return typeof source === 'string' && moduleMatches(source, modules); + } + if (decl.isVariableDeclarator()) { + const init = decl.node.init; + if ( + init && + t.isCallExpression(init) && + t.isIdentifier(init.callee, { name: 'require' }) && + init.arguments.length > 0 && + t.isStringLiteral(init.arguments[0]) + ) { + return moduleMatches(init.arguments[0].value, modules); + } + } + return false; +} + +/** + * Returns the original source text spanned by `node`, or `undefined` when + * location info is unavailable. + */ +function sourceOf(state: PluginPass, node: BabelTypes.Node): string | undefined { + const code = state.file?.code; + if (typeof code === 'string' && typeof node.start === 'number' && typeof node.end === 'number') { + return code.slice(node.start, node.end); + } + return undefined; +} + +/** + * Collects the distinct identifier names referenced in the assertion condition + * that resolve to a binding in scope (locals, params, imports) — the runtime + * values worth attaching to explain *why* the assertion failed. + * + * Only bare identifier references are collected, never sub-expressions: reading + * a variable does not run a call or trip a getter, so re-referencing these on + * the (falsy) report path can't cause a double-evaluation side effect. A call + * like `isReady()` therefore contributes only its callee name `isReady` (the + * function value), never an invocation. Unbound globals (`undefined`, `Math`, + * `console`, …) are skipped as noise, and member *properties* (`a.ready` → + * `ready`) are excluded because they sit in a non-referenced position. + * + * Caveat: this is side-effect-free only for *already-initialized* bindings. If + * the condition short-circuits past an identifier that is still in its + * temporal dead zone (a `let`/`const` declared textually after the assertion), + * reading it on the report path can throw a `ReferenceError` where the original + * condition would not have. This is rare (it requires a use-before-declaration + * that only survives via short-circuiting) but it is why the guarantee is + * "initialized bindings", not "all references". + */ +function collectValueIdentifiers(conditionPath: NodePath): string[] { + const names = new Set(); + const add = (p: NodePath): void => { + if (p.isIdentifier() && p.isReferencedIdentifier() && p.scope.getBinding(p.node.name)) { + names.add(p.node.name); + } + }; + // `traverse` visits descendants only, so check the root expression too (a bare + // `invariant(ready)` condition is the identifier itself). + add(conditionPath); + conditionPath.traverse({ + Identifier(p) { + add(p); + }, + }); + return Array.from(names); +} + +/** True when `filename` should be skipped given the `includeNodeModules` option. */ +function isNodeModulesExcluded(filename: string, includeNodeModules: boolean | string[] | undefined): boolean { + if (!filename.includes('node_modules')) { + return false; + } + if (!includeNodeModules) { + return true; + } + if (Array.isArray(includeNodeModules)) { + return !includeNodeModules.some(fragment => filename.includes(fragment)); + } + return false; +} + +/** + * Decides whether `path` is an assertion call this plugin should rewrite in the + * file `filename`, and if so returns the matched pragma and its condition + * argument. Returns `undefined` for every skip reason (SDK self-exclusion, + * `node_modules` exclusion, non-pragma callee, missing/spread condition, or a + * bare pragma that does not resolve to a known assertion module when required). + */ +function resolveInstrumentablePragma( + t: typeof BabelTypes, + path: NodePath, + filename: string, + options: SentryInvariantBabelPluginOptions, +): { pragma: string; condition: BabelTypes.Expression } | undefined { + // Never instrument the Sentry SDK itself — the plugin injects a require of the + // SDK, so rewriting its own asserts would create a circular require. + if (isSentrySdkPath(filename)) { + return undefined; + } + if (isNodeModulesExcluded(filename, options.includeNodeModules)) { + return undefined; + } + + const pragma = matchPragma(t, path.node.callee, options.pragmas ?? DEFAULT_PRAGMAS); + if (pragma === undefined) { + return undefined; + } + + const condition = path.node.arguments[0]; + if (condition === undefined || !t.isExpression(condition)) { + // Nothing to guard on (no args, or a spread) — leave the call as-is. + return undefined; + } + + // Guard against miscompiling a coincidentally-named function: for a bare + // identifier pragma, optionally require it to resolve to a known assertion + // module. On by default in node_modules, off for first-party code. + const requireResolved = options.requireResolvedImport ?? filename.includes('node_modules'); + if (requireResolved && t.isIdentifier(path.node.callee)) { + const modules = options.assertionModules ?? DEFAULT_ASSERTION_MODULES; + if (!calleeResolvesToAssertionModule(t, path.get('callee'), modules)) { + return undefined; + } + } + + return { pragma, condition }; +} + +/** + * Resolves the helper's local binding for this file, injecting it once at the + * top of the program on first use. A `require` binding (not an ESM `import`) so + * it survives in plain CommonJS dependency files under `includeNodeModules` — + * see the module doc comment above. + */ +function ensureHelperBinding( + t: typeof BabelTypes, + path: NodePath, + state: PluginPass, +): BabelTypes.Identifier { + let uid = state.get(IMPORT_UID_KEY) as BabelTypes.Identifier | undefined; + if (uid) { + return uid; + } + uid = path.scope.generateUidIdentifier(CAPTURE_FN); + const program = path.scope.getProgramParent().path as NodePath; + program.unshiftContainer('body', [ + t.variableDeclaration('var', [ + t.variableDeclarator( + t.cloneNode(uid), + t.memberExpression( + t.callExpression(t.identifier('require'), [t.stringLiteral(SENTRY_PACKAGE)]), + t.identifier(CAPTURE_FN), + ), + ), + ]), + ]); + state.set(IMPORT_UID_KEY, uid); + return uid; +} + +/** Builds the reporter's options-object properties for a matched call site. */ +function buildReportProperties( + t: typeof BabelTypes, + path: NodePath, + state: PluginPass, + filename: string, + pragma: string, + condition: BabelTypes.Expression, + options: SentryInvariantBabelPluginOptions, +): BabelTypes.ObjectProperty[] { + const properties: BabelTypes.ObjectProperty[] = [t.objectProperty(t.identifier('pragma'), t.stringLiteral(pragma))]; + + const conditionSource = sourceOf(state, condition); + if (conditionSource !== undefined) { + properties.push(t.objectProperty(t.identifier('condition'), t.stringLiteral(conditionSource))); + } + + // Attach the runtime values of the identifiers in the condition so the issue + // shows *why* it failed (e.g. `count > 0` → `{ count: 0 }`). Only evaluated on + // the report path (RHS of `cond || …`), so it costs nothing when the assertion + // holds — see `collectValueIdentifiers` for the side-effect analysis. + const conditionPath = path.get('arguments.0') as NodePath; + const valueNames = collectValueIdentifiers(conditionPath); + if (valueNames.length > 0) { + properties.push( + t.objectProperty( + t.identifier('values'), + t.objectExpression(valueNames.map(name => t.objectProperty(t.identifier(name), t.identifier(name)))), + ), + ); + } + + const messageArg = path.node.arguments[1]; + if (messageArg !== undefined && t.isExpression(messageArg)) { + properties.push(t.objectProperty(t.identifier('message'), t.cloneNode(messageArg, true))); + } + + const loc = path.node.loc; + if (loc) { + const basename = filename.split(/[\\/]/).pop() || filename; + const siteId = `${basename}:${loc.start.line}:${loc.start.column}`; + properties.push(t.objectProperty(t.identifier('siteId'), t.stringLiteral(siteId))); + } + + // For a throwing pragma (`invariant`/`assert`), preserve control flow: the + // reporter re-throws after capturing so downstream code that assumed the + // precondition held is not reached with invalid state. + const rethrowPragmas = options.rethrowPragmas ?? DEFAULT_RETHROW_PRAGMAS; + if (rethrowPragmas.includes(pragma)) { + properties.push(t.objectProperty(t.identifier('rethrow'), t.booleanLiteral(true))); + } + + // Construct the `Error` at the call site so its stack top is the assertion + // site itself — in dev AND release, with no reliance on `error.framesToPop` + // (consumed only by the dev debug symbolicator) or the `in_app` path + // heuristic. The reporter backfills a readable `.message`, so it is bare here. + properties.push(t.objectProperty(t.identifier('error'), t.newExpression(t.identifier('Error'), []))); + + return properties; +} + +export default function sentryInvariantBabelPlugin({ types: t }: BabelApi): PluginObj { + return { + name: 'sentry-invariant', + visitor: { + CallExpression(path: NodePath, state: PluginPass) { + const options = (state.opts as SentryInvariantBabelPluginOptions | undefined) ?? {}; + const filename = (state.file?.opts?.filename as string | undefined) ?? ''; + + const match = resolveInstrumentablePragma(t, path, filename, options); + if (!match) { + return; + } + const { pragma, condition } = match; + + const uid = ensureHelperBinding(t, path, state); + const properties = buildReportProperties(t, path, state, filename, pragma, condition, options); + const reportCall = t.callExpression(t.cloneNode(uid), [t.objectExpression(properties)]); + + // `cond || report()` — truthy condition short-circuits and never reports; + // a falsy condition reports (and, for throwing pragmas, re-throws). + path.replaceWith(t.logicalExpression('||', t.cloneNode(condition, true), reportCall)); + }, + }, + }; +} diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index c2f08a68fa..706f125c77 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -198,6 +198,28 @@ describe('ReactNativeErrorHandlers', () => { expect(error.stack).toBe(originalStack); }); + test('skips reporting an error already captured by Sentry and re-thrown', async () => { + // e.g. a "loud invariant" that reports a handled event before re-throwing, + // or user code doing `captureException(e); throw e;`. The re-thrown error + // carries `__sentry_captured__`, so the handler must not report it again. + const defaultHandler = jest.fn(); + (RN_GLOBAL_OBJ.ErrorUtils!.getGlobalHandler as jest.Mock).mockReturnValue(defaultHandler); + + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + const error = new Error('Loud invariant') as Error & { __sentry_captured__?: boolean }; + error.__sentry_captured__ = true; + + await errorHandlerCallback!(error, true); + await client.flush(); + + // No second event is produced for the already-captured error... + expect(client.event).toBeUndefined(); + // ...but the platform default handler still runs (redbox in dev, teardown in prod). + expect(defaultHandler).toHaveBeenCalledWith(error, true); + }); + describe('GlobalErrorBoundary integration', () => { let publishSpy: jest.SpyInstance; let hasSubscribersSpy: jest.SpyInstance; diff --git a/packages/core/test/invariant.test.ts b/packages/core/test/invariant.test.ts new file mode 100644 index 0000000000..b9fe0b63a7 --- /dev/null +++ b/packages/core/test/invariant.test.ts @@ -0,0 +1,184 @@ +import { captureException } from '@sentry/core'; + +import { captureInvariantViolation } from '../src/js/invariant'; + +const mockScope = { setFingerprint: jest.fn() }; + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + captureException: jest.fn(() => 'test-event-id'), + withScope: jest.fn((callback: (scope: unknown) => unknown) => callback(mockScope)), + }; +}); + +describe('captureInvariantViolation', () => { + beforeEach(() => { + (captureException as jest.Mock).mockClear(); + mockScope.setFingerprint.mockClear(); + }); + + test('reports a non-fatal handled event with the invariant mechanism', () => { + captureInvariantViolation({ condition: 'total >= 0', values: { total: -4 } }); + + expect(captureException).toHaveBeenCalledTimes(1); + const [error, hint] = (captureException as jest.Mock).mock.calls[0]; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Invariant violated: total >= 0'); + + expect(hint.mechanism).toEqual({ + type: 'invariant', + handled: true, + synthetic: true, + data: { + condition: 'total >= 0', + 'values.total': '-4', + values: JSON.stringify({ total: -4 }), + }, + }); + }); + + test('returns the captured event id', () => { + expect(captureInvariantViolation({ condition: 'x' })).toBe('test-event-id'); + }); + + test('uses the pragma as the mechanism type', () => { + captureInvariantViolation({ condition: 'x != null', pragma: 'assert' }); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.type).toBe('assert'); + }); + + test('flattens boolean values without stringifying them', () => { + captureInvariantViolation({ condition: 'isReady', values: { isReady: false, retries: 3 } }); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.data['values.isReady']).toBe(false); + expect(hint.mechanism.data['values.retries']).toBe('3'); + }); + + test('supports a custom message and a caller-supplied error', () => { + const error = new Error('boom'); + captureInvariantViolation({ message: 'custom', error }); + + const [captured, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(captured).toBe(error); + expect(hint.mechanism.synthetic).toBe(true); + // An error-like value carries its own stack, so no synthetic exception is attached. + expect(hint.syntheticException).toBeUndefined(); + }); + + test('backfills the message on a caller-supplied error that has none', () => { + // The Babel transform passes a bare `new Error()` created at the call site; + // its stack is kept, but the readable message is filled in by the reporter. + const error = new Error(); + captureInvariantViolation({ condition: 'total >= 0', error }); + + const [captured] = (captureException as jest.Mock).mock.calls[0]; + expect(captured).toBe(error); + expect((captured as Error).message).toBe('Invariant violated: total >= 0'); + }); + + test('groups by call site via a deterministic fingerprint', () => { + captureInvariantViolation({ + condition: 'count > 0', + pragma: 'console.assert', + siteId: 'ErrorsScreen.tsx:105:0', + }); + + expect(mockScope.setFingerprint).toHaveBeenCalledWith([ + 'loud-invariant', + 'console.assert', + 'ErrorsScreen.tsx:105:0', + ]); + }); + + test('falls back to the condition for the fingerprint when no siteId is present', () => { + captureInvariantViolation({ condition: 'total >= 0' }); + + expect(mockScope.setFingerprint).toHaveBeenCalledWith(['loud-invariant', 'invariant', 'total >= 0']); + }); + + test('omits condition/values data when not provided', () => { + captureInvariantViolation(); + + const [error, hint] = (captureException as jest.Mock).mock.calls[0]; + expect((error as Error).message).toBe('Invariant violated'); + expect(hint.mechanism.data).toEqual({}); + }); + + test('surfaces the siteId under mechanism.data', () => { + captureInvariantViolation({ condition: 'x', siteId: 'Foo.tsx:10:2' }); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.data.siteId).toBe('Foo.tsx:10:2'); + }); + + test('reports each siteId at most once per session', () => { + captureInvariantViolation({ condition: 'x', siteId: 'A.tsx:1:0' }); + const secondId = captureInvariantViolation({ condition: 'x', siteId: 'A.tsx:1:0' }); + + // A different site is unaffected by the first site's dedup. + captureInvariantViolation({ condition: 'y', siteId: 'B.tsx:2:0' }); + + expect(captureException).toHaveBeenCalledTimes(2); + // The deduped call reports nothing and returns an empty event id. + expect(secondId).toBe(''); + }); + + test('reports on every call when once is false', () => { + captureInvariantViolation({ condition: 'x', siteId: 'C.tsx:1:0', once: false }); + captureInvariantViolation({ condition: 'x', siteId: 'C.tsx:1:0', once: false }); + + expect(captureException).toHaveBeenCalledTimes(2); + }); + + test('re-throws the error after reporting when rethrow is set', () => { + const error = new Error('boom'); + expect(() => captureInvariantViolation({ condition: 'x', error, rethrow: true })).toThrow(error); + + expect(captureException).toHaveBeenCalledTimes(1); + // Tagged so the runtime's global handler skips the re-thrown error instead of + // reporting the same violation a second time as an unhandled crash. + expect((error as { __sentry_captured__?: boolean }).__sentry_captured__).toBe(true); + }); + + test('does not throw when rethrow is not set (report-only pragmas)', () => { + expect(() => captureInvariantViolation({ condition: 'x', pragma: 'warning' })).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test('re-throws even when the report is deduplicated by siteId', () => { + const first = new Error('first'); + expect(() => + captureInvariantViolation({ condition: 'x', error: first, siteId: 'R.tsx:1:0', rethrow: true }), + ).toThrow(first); + expect(captureException).toHaveBeenCalledTimes(1); + + const second = new Error('second'); + // Same site → the duplicate event is suppressed, but a violated precondition + // must still halt control flow. + expect(() => + captureInvariantViolation({ condition: 'x', error: second, siteId: 'R.tsx:1:0', rethrow: true }), + ).toThrow(second); + expect(captureException).toHaveBeenCalledTimes(1); + // The deduped rethrow is tagged too, so the global handler skips it — the + // guard must hold on this branch, which returns before the tail rethrow. + expect((second as { __sentry_captured__?: boolean }).__sentry_captured__).toBe(true); + }); + + test('caps oversized flattened values and the JSON snapshot', () => { + const big = 'x'.repeat(5000); + captureInvariantViolation({ condition: 'c', values: { big } }); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + const flattened = hint.mechanism.data['values.big'] as string; + const snapshot = hint.mechanism.data.values as string; + expect(flattened.length).toBeLessThanOrEqual(276); + expect(flattened).toContain('…[truncated]'); + expect(snapshot.length).toBeLessThanOrEqual(1044); + expect(snapshot).toContain('…[truncated]'); + }); +}); diff --git a/packages/core/test/tools/sentryBabelTransformer.test.ts b/packages/core/test/tools/sentryBabelTransformer.test.ts index 361e49575b..a8e7b20880 100644 --- a/packages/core/test/tools/sentryBabelTransformer.test.ts +++ b/packages/core/test/tools/sentryBabelTransformer.test.ts @@ -178,6 +178,89 @@ describe('SentryBabelTransformer', () => { ); }); + test('transform adds the loud invariants plugin with its options', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + loudInvariants: { pragmas: ['invariant', 'assert'] }, + }); + + createSentryBabelTransformer().transform?.(createMinimalMockedTransformOptions()); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.arrayContaining([ + [expect.objectContaining({ name: 'sentryInvariantBabelPlugin' }), { pragmas: ['invariant', 'assert'] }], + ]), + }), + ); + }); + + test('transform does not add the loud invariants plugin for node_modules by default', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ loudInvariants: {} }); + + createSentryBabelTransformer().transform?.({ + ...createMinimalMockedTransformOptions(), + filename: '/project/node_modules/dep/index.js', + }); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + const calledArgs = MockDefaultBabelTransformer.transform.mock.calls[0][0] as BabelTransformerArgs; + expect(calledArgs.plugins).not.toEqual( + expect.arrayContaining([[expect.objectContaining({ name: 'sentryInvariantBabelPlugin' }), expect.anything()]]), + ); + }); + + test('transform adds the loud invariants plugin for node_modules when includeNodeModules is set', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + loudInvariants: { includeNodeModules: true }, + }); + + createSentryBabelTransformer().transform?.({ + ...createMinimalMockedTransformOptions(), + filename: '/project/node_modules/dep/index.js', + }); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.arrayContaining([ + [expect.objectContaining({ name: 'sentryInvariantBabelPlugin' }), { includeNodeModules: true }], + ]), + }), + ); + }); + + test('transform honors an includeNodeModules array allowlist for node_modules', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + loudInvariants: { includeNodeModules: ['react-native/Libraries/Utilities'] }, + }); + + // A non-allowlisted dependency is not instrumented. + createSentryBabelTransformer().transform?.({ + ...createMinimalMockedTransformOptions(), + filename: '/project/node_modules/other-dep/index.js', + }); + const excludedArgs = MockDefaultBabelTransformer.transform.mock.calls[0][0] as BabelTransformerArgs; + expect(excludedArgs.plugins).not.toEqual( + expect.arrayContaining([[expect.objectContaining({ name: 'sentryInvariantBabelPlugin' }), expect.anything()]]), + ); + + // An allowlisted dependency path is instrumented. + createSentryBabelTransformer().transform?.({ + ...createMinimalMockedTransformOptions(), + filename: '/project/node_modules/react-native/Libraries/Utilities/Dimensions.js', + }); + const includedArgs = MockDefaultBabelTransformer.transform.mock.calls[1][0] as BabelTransformerArgs; + expect(includedArgs.plugins).toEqual( + expect.arrayContaining([ + [ + expect.objectContaining({ name: 'sentryInvariantBabelPlugin' }), + { includeNodeModules: ['react-native/Libraries/Utilities'] }, + ], + ]), + ); + }); + test.each([ [ { diff --git a/packages/core/test/tools/sentryInvariantBabelPlugin.test.ts b/packages/core/test/tools/sentryInvariantBabelPlugin.test.ts new file mode 100644 index 0000000000..0541c9e69f --- /dev/null +++ b/packages/core/test/tools/sentryInvariantBabelPlugin.test.ts @@ -0,0 +1,225 @@ +import { transformSync } from '@babel/core'; + +import type { SentryInvariantBabelPluginOptions } from '../../src/js/tools/sentryInvariantBabelPlugin'; + +import sentryInvariantBabelPlugin from '../../src/js/tools/sentryInvariantBabelPlugin'; + +function transform( + code: string, + { filename = '/app/index.tsx', options }: { filename?: string; options?: SentryInvariantBabelPluginOptions } = {}, +): string { + const result = transformSync(code, { + filename, + babelrc: false, + configFile: false, + plugins: [options ? [sentryInvariantBabelPlugin, options] : sentryInvariantBabelPlugin], + }); + return result?.code ?? ''; +} + +describe('sentryInvariantBabelPlugin', () => { + it('rewrites an `invariant` call to a non-fatal report on a falsy condition', () => { + const out = transform(`invariant(total >= 0, 'bad total');`); + + expect(out).toMatch( + /var _captureInvariantViolation\w* = require\(['"]@sentry\/react-native['"]\)\.captureInvariantViolation/, + ); + // `cond || report()` — truthy short-circuits, falsy reports. + expect(out).toMatch(/total >= 0 \|\| _captureInvariantViolation\w*\(\{/); + expect(out).toContain(`pragma: "invariant"`); + expect(out).toContain(`condition: "total >= 0"`); + expect(out).toContain(`message: 'bad total'`); + }); + + it('injects a stable per-site siteId', () => { + const out = transform(`invariant(ok);`, { filename: '/proj/src/Foo.tsx' }); + expect(out).toContain(`siteId: "Foo.tsx:1:0"`); + }); + + it('constructs the Error at the call site so its stack top is the assertion site', () => { + // The bare `new Error()` is created in the rewritten code (not inside the + // reporter), so the top frame is the assertion site in dev and release — + // without relying on framesToPop or the in_app heuristic. + const out = transform(`invariant(total >= 0, 'bad total');`); + expect(out).toMatch(/error: new Error\(\)/); + }); + + it('attaches the runtime values of identifiers referenced in the condition', () => { + const out = transform(`const count = 0;\ninvariant(count > 0, 'too few');`); + expect(out).toMatch(/values: \{\s*count: count\s*\}/); + }); + + it('captures every bound identifier in a computed member condition', () => { + // `dimensions[dim]` (React Native's Dimensions.js invariant) → both the map + // and the missing key are surfaced. + const out = transform(`const dimensions = {};\nconst dim = 'x';\ninvariant(dimensions[dim]);`); + expect(out).toMatch(/values: \{[^}]*\bdimensions: dimensions\b/); + expect(out).toMatch(/values: \{[^}]*\bdim: dim\b/); + }); + + it('excludes member properties and unbound globals from values', () => { + // `a.ready` → capture `a` (bound), not the `ready` property; `Math`/`NaN` + // have no binding and are dropped as noise. + const out = transform(`const a = { ready: false };\ninvariant(a.ready && Math.random() > NaN);`); + expect(out).toMatch(/values: \{[^}]*\ba: a\b/); + expect(out).not.toMatch(/\bready: ready\b/); + expect(out).not.toMatch(/\bMath: Math\b/); + expect(out).not.toMatch(/\bNaN: NaN\b/); + }); + + it('omits the values object when the condition references no bound identifiers', () => { + const out = transform(`invariant(1 > 0);`); + expect(out).toContain(`pragma: "invariant"`); + expect(out).not.toContain('values:'); + }); + + it('matches `assert` and `warning` identifiers', () => { + expect(transform(`assert(x != null);`)).toContain(`pragma: "assert"`); + expect(transform(`warning(isReady, 'not ready');`)).toContain(`pragma: "warning"`); + }); + + it('matches the `console.assert` member call', () => { + const out = transform(`console.assert(count > 0, 'empty');`); + expect(out).toContain(`pragma: "console.assert"`); + expect(out).toMatch(/count > 0 \|\| _captureInvariantViolation/); + }); + + it('honors a custom pragma set', () => { + const out = transform(`check(cond);\ninvariant(other);`, { options: { pragmas: ['check'] } }); + expect(out).toContain(`pragma: "check"`); + // `invariant` is not in the custom set, so it is left untouched. + expect(out).toMatch(/invariant\(other\)/); + expect(out).not.toContain(`pragma: "invariant"`); + }); + + it('injects the helper binding exactly once for multiple call sites', () => { + const out = transform(`invariant(a);\nassert(b);\nwarning(c);`); + const bindings = out.match(/require\(['"]@sentry\/react-native['"]\)\.captureInvariantViolation/g)?.length ?? 0; + expect(bindings).toBe(1); + const reports = out.match(/_captureInvariantViolation\w*\(\{/g)?.length ?? 0; + expect(reports).toBe(3); + }); + + it('injects a `require` binding (not an ESM import) so it survives in CommonJS files', () => { + // Regression: an injected ESM `import` is left untouched by Metro's ESM→CJS + // transform in plain-CommonJS dependency files and Hermes then rejects the + // bare `import` at release-build time. The helper must be a `require`. + const out = transform(`const invariant = require('invariant');\ninvariant(ok);`, { + filename: '/proj/node_modules/some-dep/index.js', + options: { includeNodeModules: true }, + }); + expect(out).toMatch(/require\(['"]@sentry\/react-native['"]\)\.captureInvariantViolation/); + expect(out).not.toMatch(/^\s*import\b/m); + }); + + it('leaves non-pragma calls alone', () => { + const out = transform(`doSomething(a, b);\nfoo.bar(c);`); + expect(out).not.toContain('@sentry/react-native'); + expect(out).not.toContain('_captureInvariantViolation'); + }); + + it('skips calls with no arguments or a leading spread', () => { + const out = transform(`invariant();\ninvariant(...args);`); + expect(out).not.toContain('_captureInvariantViolation'); + }); + + it('is idempotent — running the plugin twice does not double-instrument', () => { + const first = transform(`invariant(ok, 'msg');`); + const second = transform(first); + const reports = second.match(/_captureInvariantViolation\w*\(\{/g)?.length ?? 0; + const bindings = second.match(/require\(['"]@sentry\/react-native['"]\)\.captureInvariantViolation/g)?.length ?? 0; + expect(reports).toBe(1); + expect(bindings).toBe(1); + }); + + it('skips files inside node_modules by default', () => { + const out = transform(`invariant(ok);`, { filename: '/proj/node_modules/some-dep/index.js' }); + expect(out).not.toContain('@sentry/react-native'); + expect(out).toMatch(/invariant\(ok\)/); + }); + + it('instruments node_modules when includeNodeModules is set and the pragma resolves to an assertion module', () => { + const out = transform(`import invariant from 'invariant';\ninvariant(ok);`, { + filename: '/proj/node_modules/some-dep/index.js', + options: { includeNodeModules: true }, + }); + expect(out).toContain('@sentry/react-native'); + expect(out).toContain(`pragma: "invariant"`); + }); + + it('emits `rethrow: true` for throwing pragmas and omits it for report-only pragmas', () => { + // `invariant`/`assert` are hard preconditions — the reporter must re-throw to + // preserve control flow. `warning`/`console.assert` never threw. + expect(transform(`invariant(ok);`)).toContain('rethrow: true'); + expect(transform(`assert(ok);`)).toContain('rethrow: true'); + expect(transform(`warning(ok, 'w');`)).not.toContain('rethrow'); + expect(transform(`console.assert(ok, 'c');`)).not.toContain('rethrow'); + }); + + it('honors a custom rethrowPragmas set', () => { + expect(transform(`warning(ok, 'w');`, { options: { rethrowPragmas: ['warning'] } })).toContain('rethrow: true'); + expect(transform(`invariant(ok);`, { options: { rethrowPragmas: [] } })).not.toContain('rethrow'); + }); + + it('never instruments the Sentry SDK’s own source (installed @sentry path)', () => { + const out = transform(`import invariant from 'invariant';\ninvariant(ok);`, { + filename: '/proj/node_modules/@sentry/react-native/dist/js/foo.js', + options: { includeNodeModules: true }, + }); + expect(out).not.toContain('_captureInvariantViolation'); + expect(out).toMatch(/invariant\(ok\)/); + }); + + it('never instruments the Sentry SDK’s own source (monorepo symlink path)', () => { + // The dev symlink resolves the SDK through a path with no node_modules/@sentry + // segment, so it must be excluded by the packages/ marker too. + const out = transform(`invariant(ok);`, { + filename: '/x/sentry-react-native/packages/core/dist/js/foo.js', + }); + expect(out).not.toContain('_captureInvariantViolation'); + }); + + it('skips a coincidentally-named node_modules pragma that does not resolve to an assertion module', () => { + // A local function named `invariant` with unrelated semantics must not be + // miscompiled just because it shares the name. + const out = transform(`function invariant(x) {}\ninvariant(ok);`, { + filename: '/proj/node_modules/some-dep/index.js', + options: { includeNodeModules: true }, + }); + expect(out).not.toContain('_captureInvariantViolation'); + }); + + it('always instruments console.assert in node_modules (member pragma bypasses import resolution)', () => { + const out = transform(`console.assert(ok, 'c');`, { + filename: '/proj/node_modules/some-dep/index.js', + options: { includeNodeModules: true }, + }); + expect(out).toContain(`pragma: "console.assert"`); + }); + + it('requireResolvedImport:false instruments node_modules pragmas without import resolution', () => { + const out = transform(`invariant(ok);`, { + filename: '/proj/node_modules/some-dep/index.js', + options: { includeNodeModules: true, requireResolvedImport: false }, + }); + expect(out).toContain(`pragma: "invariant"`); + }); + + it('instruments only allowlisted node_modules paths when includeNodeModules is an array', () => { + const options = { + includeNodeModules: ['react-native/Libraries/Utilities'], + requireResolvedImport: false, + }; + const included = transform(`invariant(ok);`, { + filename: '/proj/node_modules/react-native/Libraries/Utilities/Dimensions.js', + options, + }); + expect(included).toContain(`pragma: "invariant"`); + + const excluded = transform(`invariant(ok);`, { + filename: '/proj/node_modules/other-dep/index.js', + options, + }); + expect(excluded).not.toContain('_captureInvariantViolation'); + }); +}); diff --git a/samples/react-native/metro.config.js b/samples/react-native/metro.config.js index bb5a25d8e5..7391847bde 100644 --- a/samples/react-native/metro.config.js +++ b/samples/react-native/metro.config.js @@ -17,6 +17,17 @@ const sentryConfig = withSentryConfig(mergedConfig, { annotateReactComponents: { ignoredComponents: ['BottomTabsNavigator'], }, + // "Loud Invariants" demo: rewrite `invariant`/`assert`/`warning`/`console.assert` + // call sites so a violated assertion reports a non-fatal Sentry event instead + // of throwing. First-party code is instrumented by default; the `includeNodeModules` + // allowlist narrowly extends this to React Native's Dimensions module so its + // `invariant` (e.g. `Dimensions.get('unknown')`) becomes loud with no source + // changes — without blanket-instrumenting all of node_modules. + loudInvariants: { + includeNodeModules: ['react-native/Libraries/Utilities/Dimensions'], + // The demo targets one known module, so skip import-resolution gating. + requireResolvedImport: false, + }, }); module.exports = withMonorepo(sentryConfig); diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index c4098191d0..159418b3e1 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import { ButtonProps, Button as NativeButton, + Dimensions, NativeModules, Platform, ScrollView, @@ -63,6 +64,56 @@ const ErrorsScreen = (_props: Props) => { Sentry.captureException(new Error('Captured exception')); }} /> +