diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad19bf7ea..ba04ef3df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add opt-in Metro transform that reports violated `invariant`/`assert`/`warning`/`console.assert` assertions as non-fatal Sentry events instead of crashing or being stripped ([#6592](https://github.com/getsentry/sentry-react-native/pull/6592)) + + Enable it by passing `captureAssertions: true` (or an options object) to `withSentryConfig` in your `metro.config.js`; omit it or set `captureAssertions: false` to disable. Hard preconditions (`invariant`/`assert`) still throw after reporting — you gain a readable, grouped event, not crash suppression. + ## 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..fe1b51aa35 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -151,6 +151,19 @@ export const appStartIntegration: (input?: { standalone?: boolean; }) => AppStartIntegration; +// @public (undocumented) +export interface AssertionViolationOptions { + condition?: string; + error?: Error; + message?: string; + messageArgs?: unknown[]; + once?: boolean; + pragma?: string; + rethrow?: boolean; + siteId?: string; + values?: Record; +} + export { Breadcrumb } // Warning: (ae-forgotten-export) The symbol "BreadcrumbsOptions" needs to be exported by the entry point index.d.ts @@ -170,6 +183,9 @@ export { browserLinkedErrorsIntegration } // @public export const browserReplayIntegration: (options?: ReplayConfiguration) => Replay; +// @public +export function captureAssertionViolation(options?: AssertionViolationOptions): string; + export { captureEvent } export { captureException } @@ -223,6 +239,9 @@ export const deeplinkIntegration: (...args: any[]) => Integration & { name: string; }; +// @public +export const DEFAULT_ASSERTION_MECHANISM = "assertion"; + // @public export const deviceContextIntegration: () => Integration; diff --git a/packages/core/src/js/assertion.ts b/packages/core/src/js/assertion.ts new file mode 100644 index 0000000000..4e229382d5 --- /dev/null +++ b/packages/core/src/js/assertion.ts @@ -0,0 +1,363 @@ +import { addNonEnumerableProperty, captureException, withScope } from '@sentry/core'; + +import { createSyntheticError, isErrorLike } from './utils/error'; + +/** + * Mechanism type reported for every assertion violation. + * + * Uniform across all pragmas — the specific pragma (`invariant`, `assert`, + * `warning`, `console.assert`, ...) is recorded under `mechanism.data.pragma` + * instead, so violations can be filtered by flavor without fragmenting the + * mechanism type. 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_ASSERTION_MECHANISM = 'assertion'; + +export interface AssertionViolationOptions { + /** + * 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 failed the assertion, 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`, ...). Recorded under `mechanism.data.pragma` + * so violations can be filtered by the assertion flavor, while the mechanism + * type stays the uniform `'assertion'` (`DEFAULT_ASSERTION_MECHANISM`). Only + * added to `mechanism.data` when provided. + */ + pragma?: string; + /** + * Human-readable message. Defaults to `Assertion failed: `. When + * `messageArgs` are present, this is treated as a `util.format`-style template. + */ + message?: string; + /** + * Substitution arguments for a variadic pragma (`invariant(cond, fmt, ...args)`, + * `console.assert(cond, fmt, ...args)`). The Babel transform forwards the call's + * arguments beyond the format string here so the `%s`/`%d`/`%o`/... specifiers + * in `message` are interpolated instead of surfacing verbatim. Extra args with + * no matching specifier are appended to the message. + * + * Evaluated only on the report (falsy) path, so their cost — and any side + * effects — are deferred to an actual violation, consistent with the + * `cond || report()` rewrite. + */ + messageArgs?: unknown[]; + /** + * 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; +/** Max number of `values.` entries emitted before the rest are dropped. */ +const MAX_VALUE_ENTRIES = 50; + +/** 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; +} + +/** + * Coerces a single runtime value to a string for `mechanism.data`. Defensive on + * purpose: `String(symbol)` throws a `TypeError`, and a value can carry a + * throwing `toString`/`Symbol.toPrimitive`, so a naive `String(value)` would + * crash the reporting path — exactly the path that must never throw. Symbols are + * rendered via `.toString()`; anything else that throws falls back to its type. + */ +function stringifyValue(value: unknown): string { + try { + return typeof value === 'symbol' ? value.toString() : String(value); + } catch (_e) { + return `[unstringifiable ${typeof value}]`; + } +} + +/** + * Interpolates a `console.assert`/`invariant`/`warning`-style format string with + * its substitution arguments. These pragmas are variadic (`invariant(cond, fmt, + * ...args)`); the Babel transform forwards the extra args as `messageArgs` so the + * `%s`/`%d`/`%o`/... specifiers resolve instead of reaching Sentry verbatim. + * + * A pragmatic subset of the Node/browser `util.format` specifiers is supported. + * Every substitution flows through `stringifyValue`, so interpolation inherits + * the same no-throw guarantee. Args left over after the specifiers are consumed + * are appended space-separated (matching `console.assert(cond, a, b)`); a + * specifier with no remaining arg is left verbatim. + */ +function safeNumber(arg: unknown): number { + try { + return Number(arg); + } catch (_e) { + // `Number(symbol)` throws a TypeError; the reporting path must never throw, + // so a non-coercible arg renders as `NaN` (matching `util.format`). + return NaN; + } +} + +function formatMessage(template: string, args: unknown[]): string { + let i = 0; + const out = template.replace(/%[sdifjoOc%]/g, spec => { + if (spec === '%%') { + return '%'; + } + if (spec === '%c') { + i++; // CSS directive consumes an arg but renders nothing. + return ''; + } + if (i >= args.length) { + return spec; + } + const arg = args[i++]; + switch (spec) { + case '%d': + case '%i': + return String(Math.trunc(safeNumber(arg))); + case '%f': + return String(safeNumber(arg)); + case '%j': + try { + return JSON.stringify(arg) ?? 'undefined'; + } catch (_e) { + return '[circular]'; + } + default: + // %s, %o, %O + return stringifyValue(arg); + } + }); + const rest = args.slice(i).map(stringifyValue); + return rest.length > 0 ? `${out} ${rest.join(' ')}` : out; +} + +/** + * Resolves the human-readable message: a caller `message` (interpolated with + * `messageArgs` when present), else `Assertion failed: `, else the + * bare fallback. A non-string `message` is coerced defensively. + */ +function buildMessage(message: unknown, messageArgs: unknown[] | undefined, condition: string | undefined): string { + if (typeof message === 'string') { + return messageArgs && messageArgs.length > 0 ? formatMessage(message, messageArgs) : message; + } + if (message !== undefined) { + return stringifyValue(message); + } + return condition ? `Assertion failed: ${condition}` : 'Assertion failed'; +} + +/** + * 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. + * + * Three bounds keep a large captured object from bloating the event payload or + * burning CPU on the no-throw reporting path: the number of keys is capped + * (`MAX_VALUE_ENTRIES`), each entry is length-capped (`MAX_VALUE_LENGTH`), and + * the JSON snapshot — built from only the capped subset, never the full input — + * is length-capped (`MAX_SNAPSHOT_LENGTH`). The Babel transform only ever emits + * a handful of condition identifiers, but `captureAssertionViolation` is public + * and can be handed an arbitrarily large object (e.g. a config or array with + * millions of indices) by hand. + */ +function flattenValues(values: Record): { [key: string]: string | boolean } { + const data: { [key: string]: string | boolean } = {}; + const keys = Object.keys(values); + const cappedKeys = keys.length > MAX_VALUE_ENTRIES ? keys.slice(0, MAX_VALUE_ENTRIES) : keys; + // Only materialize a subset object when we actually truncated, so the common + // (small) case snapshots the input directly with no extra allocation. + const truncated = cappedKeys.length < keys.length; + const snapshotSource: Record = truncated ? {} : values; + for (const key of cappedKeys) { + try { + const value = values[key]; + data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(stringifyValue(value), MAX_VALUE_LENGTH); + if (truncated) { + snapshotSource[key] = value; + } + } catch (_e) { + // A throwing getter must not break the no-throw reporting path. + data[`values.${key}`] = '[unreadable]'; + } + } + if (truncated) { + data['values.__truncated__'] = `${keys.length - cappedKeys.length} more keys omitted`; + } + try { + data.values = truncate(JSON.stringify(snapshotSource) ?? 'undefined', MAX_SNAPSHOT_LENGTH); + } catch (_e) { + // Circular or non-serializable values — the flattened entries above still apply. + } + return data; +} + +/** + * Reports a violated assertion to Sentry as a non-fatal (handled) event without + * throwing or crashing the app. + * + * This is the runtime target of the Sentry assertion Babel 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 captureAssertionViolation(options: AssertionViolationOptions = {}): string { + const { condition, values, pragma, siteId, once = true, rethrow = false } = options; + + const message = buildMessage(options.message, options.messageArgs, condition); + + 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. Guarded because the public + // API is untyped at runtime: `error` can be a frozen object, carry a read-only + // `message` accessor, or be a non-object primitive, any of which would throw a + // `TypeError` on the assignment (in strict mode) — and this path must never + // throw. If backfilling fails the caller's error rides along unchanged; the + // message still reaches the event via `mechanism.data`/fingerprint. + try { + if (!error.message) { + error.message = message; + } + } catch (_e) { + // Read-only/frozen/non-object error — leave it as-is. + } + + // 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 (pragma !== undefined) { + data.pragma = pragma; + } + if (condition !== undefined) { + data.condition = condition; + } + if (siteId !== undefined) { + data.siteId = siteId; + } + // `!= null` (not `!== undefined`): a hand-written call can pass `values: null`, + // and `flattenValues` → `Object.keys(null)` would throw on the no-throw path. + if (values != null) { + 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 assertions 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(['sentry-assertion', pragma ?? DEFAULT_ASSERTION_MECHANISM, 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. + // `type` is the uniform assertion mechanism; the specific pragma lives in + // `data.pragma`. + mechanism: { + type: DEFAULT_ASSERTION_MECHANISM, + 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/index.ts b/packages/core/src/js/index.ts index 6550c89cb2..e91bde7cd6 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 { captureAssertionViolation, DEFAULT_ASSERTION_MECHANISM } from './assertion'; +export type { AssertionViolationOptions } from './assertion'; 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..f7db17c527 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 captured + // assertion violation 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/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 34464203fc..5e299583a6 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 { SentryAssertionBabelPluginOptions } from './sentryAssertionBabelPlugin'; 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` and + * `console.assert` (all four pragmas, not just literal `assert`) — so a + * violated assertion 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 + */ + captureAssertions?: boolean | SentryAssertionBabelPluginOptions; } export interface SentryExpoConfigOptions { @@ -119,6 +133,7 @@ export function withSentryConfig( enableSourceContextInDevelopment = true, optionsFile = true, autoWrapExpoRouterErrorBoundary = false, + captureAssertions = 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 || captureAssertions) { + newConfig = withSentryBabelTransformer( + newConfig, + annotateReactComponents, + autoWrapExpoRouterErrorBoundary, + captureAssertions, + ); } 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 captureAssertions = options.captureAssertions ?? false; + if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary || captureAssertions) { newConfig = withSentryBabelTransformer( newConfig, options.annotateReactComponents ?? false, autoWrapExpoRouterErrorBoundary, + captureAssertions, ); } @@ -225,6 +247,7 @@ export function withSentryBabelTransformer( | boolean | { ignoredComponents?: string[]; autoInjectSentryLabel?: boolean; textComponentNames?: string[] }, autoWrapExpoRouterErrorBoundary: boolean = false, + captureAssertions: SentryMetroConfigOptions['captureAssertions'] = 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, + ...(captureAssertions ? { captureAssertions: typeof captureAssertions === 'object' ? captureAssertions : {} } : {}), }); return { diff --git a/packages/core/src/js/tools/sentryAssertionBabelPlugin.ts b/packages/core/src/js/tools/sentryAssertionBabelPlugin.ts new file mode 100644 index 0000000000..32d07c9a1a --- /dev/null +++ b/packages/core/src/js/tools/sentryAssertionBabelPlugin.ts @@ -0,0 +1,654 @@ +import type { NodePath, PluginObj, PluginPass, types as BabelTypes } from '@babel/core'; + +/** + * Babel plugin that rewrites assertion call sites so a violated assertion 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 _captureAssertionViolation = require('@sentry/react-native').captureAssertionViolation; + * // ... + * total >= 0 || _captureAssertionViolation({ + * 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'; +/** + * The exported runtime reporter this plugin injects a call to. MUST stay in + * sync with the actual `@sentry/react-native` export name — the plugin emits + * `require('@sentry/react-native').`, so a rename on one side + * without the other breaks at runtime with an undefined helper. A test asserts + * the two match (see `sentryAssertionBabelPlugin.test.ts`). + */ +export const CAPTURE_FN = 'captureAssertionViolation'; +/** Per-file state key holding the injected helper's local identifier. */ +const IMPORT_UID_KEY = 'sentryAssertionCaptureUid'; +/** Per-file state key for the hoisted `Error`-constructor alias. */ +const ERROR_UID_KEY = 'sentryAssertionErrorUid'; + +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 + * `@sentry-internal` scope holds packages the SDK depends on transitively, so + * instrumenting them would create the same circular require. The last marker + * covers the monorepo dev symlink, whose path has no `node_modules/@sentry` + * segment. + */ +const SENTRY_SDK_PATH_MARKERS = ['/@sentry/', '/@sentry-internal/', 'sentry-react-native/packages/']; + +export interface SentryAssertionBabelPluginOptions { + /** + * 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`). + * + * Matching is purely syntactic on the callee shape — it does not follow aliases, + * which has two consequences: + * + * - A renamed pragma slips through unmatched: `const inv = invariant; inv(x)` is + * left untouched because the callee is a bare `inv`, not `invariant`. Harmless + * (the site is simply not instrumented). + * - A pragma destructured off its object is matched as the *bare* pragma, not the + * member one: `const { assert } = console; assert(false)` compiles under the + * `assert` pragma (a throwing precondition), not `console.assert` (report-only). + * Because the two default pragmas differ in `rethrow`, this flips a report-only + * `console.assert` into a throwing assertion. Following the binding back to + * `console` would require flow analysis and still risks miscompiling an + * unrelated same-named local, so it is left syntactic by design. + * + * Call the pragma by its canonical form (`console.assert(...)`, `invariant(...)`) + * at the site you want instrumented; if you must destructure `console.assert`, + * drop `assert` from `pragmas` (or drop it from `rethrowPragmas`) to avoid the + * throwing rewrite. + */ +/** + * Coerces a user-supplied pragma option to a string array. A non-array value + * (e.g. a bare string passed through Metro config) would otherwise crash the + * build with an opaque `TypeError` the first time `.includes()` is called on it. + * Mirrors the `Array.isArray` guard already applied to `includeNodeModules`. + */ +function asPragmaList(value: unknown, fallback: string[]): string[] { + return Array.isArray(value) ? value : fallback; +} + +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; +} + +/** + * Normalizes path separators to forward slashes so substring matching works on + * Windows, where Babel/Metro `filename` values contain backslashes. + */ +function toPosixPath(filename: string): string { + return filename.replace(/\\/g, '/'); +} + +/** + * Builds the path portion of a `siteId`. The `siteId` feeds both per-session + * dedup and the issue fingerprint, so it must be unique per call site: a bare + * basename collides across the many `index.tsx` / `index.ts` files a real app + * has, silently deduplicating (and cross-grouping) assertions in different + * directories. + * + * Prefers the path relative to the Babel `root` (the project root Metro passes), + * which is stable within a built bundle and machine-independent. When `filename` + * is outside `root` (or `root` is absent) it falls back to the last two path + * segments (`parentDir/basename`), which still disambiguates the common + * same-basename-in-sibling-directories collision. + */ +function relativeSitePath(filename: string, root: string | undefined): string { + const normalized = toPosixPath(filename); + const normalizedRoot = root ? toPosixPath(root).replace(/\/+$/, '') : ''; + if (normalizedRoot && normalized.startsWith(`${normalizedRoot}/`)) { + return normalized.slice(normalizedRoot.length + 1); + } + const segments = normalized.split('/').filter(Boolean); + return segments.slice(-2).join('/') || normalized; +} + +/** + * True when `filename` belongs to the Sentry SDK's own source. Separators are + * normalized first: the markers use forward slashes, so without this an SDK path + * with Windows backslashes would slip through and get a self-referential + * `require('@sentry/react-native')` injected into the package that defines it. + */ +function isSentrySdkPath(filename: string): boolean { + const normalized = toPosixPath(filename); + return SENTRY_SDK_PATH_MARKERS.some(marker => normalized.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. + * + * Identifiers bound *inside* the condition are excluded: the emitted `values` + * object is evaluated at the call site, so a name bound in a nested scope — e.g. + * the `x` parameter in `invariant(items.every(x => x > 0))` — is out of scope + * there and would throw a `ReferenceError` on the (falsy) report path. Only + * identifiers whose binding is visible from the call-site scope are collected; + * `items` is captured, `x` is not. + * + * A `let`/`const` binding declared textually *after* the call site is dropped: + * on the falsy report path the emitted `values` reads it, and if the original + * condition only reached it via short-circuit the read would hit its temporal + * dead zone here — a `ReferenceError` the original never threw. (A deferred + * call could initialize it before running, so this may drop a safe value, but + * dropping a value is always preferable to crashing the report path.) + */ +function collectValueIdentifiers(conditionPath: NodePath): string[] { + const names = new Set(); + // The `values` object is emitted in the call-site scope, so only capture + // identifiers that resolve to a binding visible there. An identifier bound in + // a nested scope (an arrow param, a callback local) resolves to a different + // binding than the call-site scope sees — or to none — so it is dropped. + const callSiteScope = conditionPath.scope; + // The `values` object is emitted at the call site's position; a lexical + // binding whose declaration starts after this is in its TDZ here. + const useStart = conditionPath.node.start; + const add = (p: NodePath): void => { + if (!p.isIdentifier() || !p.isReferencedIdentifier()) { + return; + } + const name = p.node.name; + const binding = p.scope.getBinding(name); + if (!binding || callSiteScope.getBinding(name) !== binding) { + return; + } + if (binding.kind === 'let' || binding.kind === 'const') { + // Class declarations also register as `let`, so this covers them too. + const declStart = binding.path.node.start; + if (typeof declStart === 'number' && typeof useStart === 'number' && declStart > useStart) { + return; + } + } + names.add(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` sits inside a real `node_modules` directory segment. + * Matches the `node_modules/` path segment, not the bare substring — a + * first-party file like `src/utils/node_modules_helper.ts` is not a dependency. + */ +function isInNodeModules(filename: string): boolean { + return /(?:^|\/)node_modules\//.test(toPosixPath(filename)); +} + +/** True when `filename` should be skipped given the `includeNodeModules` option. */ +function isNodeModulesExcluded(filename: string, includeNodeModules: boolean | string[] | undefined): boolean { + if (!isInNodeModules(filename)) { + return false; + } + if (!includeNodeModules) { + return true; + } + if (Array.isArray(includeNodeModules)) { + // Allowlist fragments are written with forward slashes; normalize so they + // still match on Windows (backslash) paths. + const normalized = toPosixPath(filename); + return !includeNodeModules.some(fragment => normalized.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: SentryAssertionBabelPluginOptions, +): { 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, asPragmaList(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 ?? isInNodeModules(filename); + 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; +} + +/** + * Resolves a hoisted alias of the global `Error` constructor for this file, + * injecting `var _Error = Error;` once at the top of the program on first use. + * + * The transform emits `new Error()` at the assertion call site (so the stack top + * is the site itself). Referencing `Error` directly there would bind to whatever + * `Error` is in scope — a local `let Error`, a parameter, an import — and a + * non-constructable shadow would throw `TypeError: Error is not a constructor` + * exactly when the assertion fires. The alias is captured at program top, where + * `Error` is the global, so the injected `new _Error()` is immune to call-site + * shadowing. + */ +function ensureErrorBinding( + t: typeof BabelTypes, + path: NodePath, + state: PluginPass, +): BabelTypes.Identifier { + let uid = state.get(ERROR_UID_KEY) as BabelTypes.Identifier | undefined; + if (uid) { + return uid; + } + uid = path.scope.generateUidIdentifier('Error'); + const program = path.scope.getProgramParent().path as NodePath; + // `var _Error = typeof globalThis !== 'undefined' ? globalThis.Error : Error;` + // Reading `globalThis.Error` (not the bare `Error` identifier) means a + // module-level `const`/`let`/`class Error` — which would put the bare + // identifier in its TDZ at program top, or a `var Error` shadow that reads + // `undefined` — can't corrupt the alias. The bare-`Error` fallback is only + // reached on ancient runtimes without `globalThis`, where the shadow is moot. + const errorInit = t.conditionalExpression( + t.binaryExpression('!==', t.unaryExpression('typeof', t.identifier('globalThis')), t.stringLiteral('undefined')), + t.memberExpression(t.identifier('globalThis'), t.identifier('Error')), + t.identifier('Error'), + ); + program.unshiftContainer('body', [t.variableDeclaration('var', [t.variableDeclarator(t.cloneNode(uid), errorInit)])]); + state.set(ERROR_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: SentryAssertionBabelPluginOptions, +): 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 => + // A bare `__proto__: v` key is the prototype-setter syntax, not a + // data property, and throws for a non-object value. Emit it as a + // computed key (`['__proto__']: v`) so it stays an own property. + name === '__proto__' + ? t.objectProperty(t.stringLiteral('__proto__'), t.identifier('__proto__'), /* computed */ true) + : 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))); + + // Forward the variadic substitution args (`invariant(cond, fmt, ...args)`, + // `console.assert(cond, fmt, ...args)`) so the reporter interpolates the + // `%s`/`%d`/... specifiers instead of surfacing the literal format string. + // Cloned into an array literal on the report (RHS) path, so — like the + // condition's `values` — they are only evaluated when the assertion fails. + const extraArgs = path.node.arguments + .slice(2) + .filter((a): a is BabelTypes.Expression | BabelTypes.SpreadElement => t.isExpression(a) || t.isSpreadElement(a)); + if (extraArgs.length > 0) { + properties.push( + t.objectProperty(t.identifier('messageArgs'), t.arrayExpression(extraArgs.map(a => t.cloneNode(a, true)))), + ); + } + } + + const loc = path.node.loc; + if (loc) { + const root = state.file?.opts?.root as string | undefined; + const sitePath = relativeSitePath(filename, root); + const siteId = `${sitePath}:${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 = asPragmaList(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. + // Use the hoisted global-`Error` alias so a call-site shadow can't turn this + // into a `TypeError` when the assertion fires — see `ensureErrorBinding`. + const errorUid = ensureErrorBinding(t, path, state); + properties.push(t.objectProperty(t.identifier('error'), t.newExpression(t.cloneNode(errorUid), []))); + + return properties; +} + +export default function sentryAssertionBabelPlugin({ types: t }: BabelApi): PluginObj { + return { + name: 'sentry-assertion', + visitor: { + CallExpression(path: NodePath, state: PluginPass) { + const options = (state.opts as SentryAssertionBabelPluginOptions | undefined) ?? {}; + const filename = (state.file?.opts?.filename as string | undefined) ?? ''; + + // Only rewrite a standalone assertion statement. As a subexpression + // (`warning(cond) && next()`), the `cond || report()` rewrite would + // change the value and branching — `report()` returns a truthy event id + // where the pragma returned `undefined` — so those calls are left alone. + if (!path.parentPath.isExpressionStatement()) { + return; + } + + 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/src/js/tools/sentryBabelTransformerUtils.ts b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts index 5b39e5959d..c9fd384cb6 100644 --- a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts +++ b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts @@ -2,8 +2,10 @@ import componentAnnotatePlugin from '@sentry/bundler-plugins/babel-plugin'; import { debug } from '@sentry/core'; import * as process from 'process'; +import type { SentryAssertionBabelPluginOptions } from './sentryAssertionBabelPlugin'; import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; +import sentryAssertionBabelPlugin from './sentryAssertionBabelPlugin'; import sentryExpoRouterAutoWrapBabelPlugin from './sentryExpoRouterAutoWrapBabelPlugin'; export type SentryBabelTransformerOptions = { @@ -13,6 +15,7 @@ export type SentryBabelTransformerOptions = { textComponentNames?: string[]; }; autoWrapExpoRouterErrorBoundary?: boolean; + captureAssertions?: SentryAssertionBabelPluginOptions; }; 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?.captureAssertions !== undefined) { + addSentryCaptureAssertionsPlugin(transformerArgs, options.captureAssertions); + } return defaultTransformer.transform(...args); }; @@ -142,3 +148,31 @@ function addSentryExpoRouterAutoWrapPlugin(args: BabelTransformerArgs | undefine } args.plugins.push([sentryExpoRouterAutoWrapBabelPlugin, {}]); } + +function addSentryCaptureAssertionsPlugin( + 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; + // Normalize separators so the allowlist (written with forward slashes) matches + // on Windows, where Babel/Metro pass backslash paths — mirroring the plugin's + // own `toPosixPath` normalization in `isNodeModulesExcluded`. + const normalizedFilename = args.filename.replace(/\\/g, '/'); + if (normalizedFilename.includes('node_modules')) { + if (!inc) { + return undefined; + } + if (Array.isArray(inc) && !inc.some(fragment => normalizedFilename.includes(fragment))) { + return undefined; + } + } + args.plugins.push([sentryAssertionBabelPlugin, options]); +} diff --git a/packages/core/test/assertion.test.ts b/packages/core/test/assertion.test.ts new file mode 100644 index 0000000000..fa5f216915 --- /dev/null +++ b/packages/core/test/assertion.test.ts @@ -0,0 +1,307 @@ +import { captureException } from '@sentry/core'; + +import { captureAssertionViolation } from '../src/js/assertion'; + +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('captureAssertionViolation', () => { + beforeEach(() => { + (captureException as jest.Mock).mockClear(); + mockScope.setFingerprint.mockClear(); + }); + + test('reports a non-fatal handled event with the assertion mechanism', () => { + captureAssertionViolation({ 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('Assertion failed: total >= 0'); + + expect(hint.mechanism).toEqual({ + type: 'assertion', + handled: true, + synthetic: true, + data: { + condition: 'total >= 0', + 'values.total': '-4', + values: JSON.stringify({ total: -4 }), + }, + }); + }); + + test('returns the captured event id', () => { + expect(captureAssertionViolation({ condition: 'x' })).toBe('test-event-id'); + }); + + test('records the pragma under mechanism.data with a uniform mechanism type', () => { + captureAssertionViolation({ condition: 'x != null', pragma: 'assert' }); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.type).toBe('assertion'); + expect(hint.mechanism.data.pragma).toBe('assert'); + }); + + test('flattens boolean values without stringifying them', () => { + captureAssertionViolation({ 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'); + captureAssertionViolation({ 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(); + captureAssertionViolation({ condition: 'total >= 0', error }); + + const [captured] = (captureException as jest.Mock).mock.calls[0]; + expect(captured).toBe(error); + expect((captured as Error).message).toBe('Assertion failed: total >= 0'); + }); + + test('does not throw when backfilling the message on a frozen error', () => { + // The public API is untyped at runtime; a frozen error (or one with a + // read-only `message`) must not break the no-throw reporting path when the + // reporter tries to backfill the default message. + const frozen = Object.freeze(new Error()); + expect(() => captureAssertionViolation({ condition: 'total >= 0', error: frozen })).not.toThrow(); + + const [captured] = (captureException as jest.Mock).mock.calls[0]; + expect(captured).toBe(frozen); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test('does not throw when a non-object error is passed', () => { + // A primitive `error` short-circuits the `?? new Error()` fallback; assigning + // `.message` to it throws in strict mode, so the backfill must be guarded. + expect(() => captureAssertionViolation({ condition: 'x', error: 'boom' as unknown as Error })).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test('groups by call site via a deterministic fingerprint', () => { + captureAssertionViolation({ + condition: 'count > 0', + pragma: 'console.assert', + siteId: 'ErrorsScreen.tsx:105:0', + }); + + expect(mockScope.setFingerprint).toHaveBeenCalledWith([ + 'sentry-assertion', + 'console.assert', + 'ErrorsScreen.tsx:105:0', + ]); + }); + + test('falls back to the condition for the fingerprint when no siteId is present', () => { + captureAssertionViolation({ condition: 'total >= 0' }); + + expect(mockScope.setFingerprint).toHaveBeenCalledWith(['sentry-assertion', 'assertion', 'total >= 0']); + }); + + test('omits condition/values data when not provided', () => { + captureAssertionViolation(); + + const [error, hint] = (captureException as jest.Mock).mock.calls[0]; + expect((error as Error).message).toBe('Assertion failed'); + expect(hint.mechanism.data).toEqual({}); + }); + + test('surfaces the siteId under mechanism.data', () => { + captureAssertionViolation({ 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', () => { + // Isolate the module registry so `reportedSites` starts empty regardless of + // which siteIds other tests reported — the dedup set is module-level state. + jest.isolateModules(() => { + const { captureException: freshCapture } = require('@sentry/core'); + const { captureAssertionViolation: report } = require('../src/js/assertion'); + + report({ condition: 'x', siteId: 'A.tsx:1:0' }); + const secondId = report({ condition: 'x', siteId: 'A.tsx:1:0' }); + + // A different site is unaffected by the first site's dedup. + report({ condition: 'y', siteId: 'B.tsx:2:0' }); + + expect(freshCapture).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', () => { + captureAssertionViolation({ condition: 'x', siteId: 'C.tsx:1:0', once: false }); + captureAssertionViolation({ 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(() => captureAssertionViolation({ 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(() => captureAssertionViolation({ condition: 'x', pragma: 'warning' })).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test('re-throws even when the report is deduplicated by siteId', () => { + // Isolated so the first call is guaranteed to be this site's first sighting. + jest.isolateModules(() => { + const { captureException: freshCapture } = require('@sentry/core'); + const { captureAssertionViolation: report } = require('../src/js/assertion'); + + const first = new Error('first'); + expect(() => report({ condition: 'x', error: first, siteId: 'R.tsx:1:0', rethrow: true })).toThrow(first); + expect(freshCapture).toHaveBeenCalledTimes(1); + + const second = new Error('second'); + // Same site → the duplicate event is suppressed, but a violated precondition + // must still halt control flow. + expect(() => report({ condition: 'x', error: second, siteId: 'R.tsx:1:0', rethrow: true })).toThrow(second); + expect(freshCapture).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('stringifies a Symbol value without throwing', () => { + // `String(symbol)` throws a TypeError; the reporting path must never throw, + // and the auto-captured `values` can hold a symbol when the condition + // references a symbol-valued identifier. + const sym = Symbol('token'); + expect(() => captureAssertionViolation({ condition: 'token', values: { token: sym } })).not.toThrow(); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.data['values.token']).toBe('Symbol(token)'); + }); + + test('falls back gracefully when a value throws on stringification', () => { + const hostile = { + toString() { + throw new Error('nope'); + }, + }; + expect(() => captureAssertionViolation({ condition: 'x', values: { x: hostile } })).not.toThrow(); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.data['values.x']).toBe('[unstringifiable object]'); + }); + + test('does not throw when values is null', () => { + // The public API can be called by hand with `values: null`; `Object.keys(null)` + // would otherwise throw on the no-throw reporting path. + expect(() => captureAssertionViolation({ condition: 'x', values: null as never })).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + test('does not throw when a value has a throwing getter', () => { + const values = {}; + Object.defineProperty(values, 'boom', { + enumerable: true, + get() { + throw new Error('nope'); + }, + }); + expect(() => captureAssertionViolation({ condition: 'x', values })).not.toThrow(); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + expect(hint.mechanism.data['values.boom']).toBe('[unreadable]'); + }); + + test('interpolates messageArgs into the message format string', () => { + // The variadic RN Dimensions invariant: `invariant(dims, '... %s', key)`. + captureAssertionViolation({ message: 'No dimension set for key %s', messageArgs: ['window'] }); + + const [error] = (captureException as jest.Mock).mock.calls[0]; + expect((error as Error).message).toBe('No dimension set for key window'); + }); + + test('interpolates %d/%j and appends extra args, leaving a dangling specifier verbatim', () => { + captureAssertionViolation({ message: 'n=%d obj=%j missing=%s', messageArgs: [3.9, { a: 1 }, 'x', 'y'] }); + + const [error] = (captureException as jest.Mock).mock.calls[0]; + // %d truncates, %j serializes, the two consumed the first three args, the + // trailing 'y' is appended, and the un-fed %s is left literal. + expect((error as Error).message).toBe('n=3 obj={"a":1} missing=x y'); + }); + + test('renders a non-coercible %d arg as NaN instead of throwing', () => { + // `Number(symbol)` throws a TypeError; the numeric specifiers must not break + // the no-throw reporting path. + const sym = Symbol('x'); + expect(() => captureAssertionViolation({ message: 'id %d', messageArgs: [sym] })).not.toThrow(); + + const [error] = (captureException as jest.Mock).mock.calls[0]; + expect((error as Error).message).toBe('id NaN'); + }); + + test('caps oversized flattened values and the JSON snapshot', () => { + const big = 'x'.repeat(5000); + captureAssertionViolation({ 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]'); + }); + + test('caps the number of flattened value entries and snapshots only the subset', () => { + // The public API can be handed an object with a huge key count; each entry + // is length-capped, but the entry *count* and the full-object JSON.stringify + // must be bounded too so a million-key object can't exhaust CPU/memory on + // the no-throw reporting path. + const huge: Record = {}; + for (let i = 0; i < 1000; i++) { + huge[`k${i}`] = i; + } + expect(() => captureAssertionViolation({ condition: 'c', values: huge })).not.toThrow(); + + const [, hint] = (captureException as jest.Mock).mock.calls[0]; + const emitted = Object.keys(hint.mechanism.data).filter( + k => k.startsWith('values.') && k !== 'values.__truncated__', + ); + // At most MAX_VALUE_ENTRIES (50) per-key entries survive. + expect(emitted.length).toBe(50); + expect(hint.mechanism.data['values.__truncated__']).toBe('950 more keys omitted'); + // The snapshot is built from the capped subset, so it never contains a + // key beyond the cap (e.g. `k999`). + expect(hint.mechanism.data.values as string).not.toContain('k999'); + }); +}); 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/tools/sentryAssertionBabelPlugin.test.ts b/packages/core/test/tools/sentryAssertionBabelPlugin.test.ts new file mode 100644 index 0000000000..b6bec61f6f --- /dev/null +++ b/packages/core/test/tools/sentryAssertionBabelPlugin.test.ts @@ -0,0 +1,393 @@ +import { transformSync } from '@babel/core'; + +import type { SentryAssertionBabelPluginOptions } from '../../src/js/tools/sentryAssertionBabelPlugin'; + +import { captureAssertionViolation } from '../../src/js/assertion'; +import sentryAssertionBabelPlugin, { CAPTURE_FN } from '../../src/js/tools/sentryAssertionBabelPlugin'; + +function transform( + code: string, + { + filename = '/app/index.tsx', + options, + root, + }: { filename?: string; options?: SentryAssertionBabelPluginOptions; root?: string } = {}, +): string { + const result = transformSync(code, { + filename, + root, + babelrc: false, + configFile: false, + plugins: [options ? [sentryAssertionBabelPlugin, options] : sentryAssertionBabelPlugin], + }); + return result?.code ?? ''; +} + +describe('sentryAssertionBabelPlugin', () => { + 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 _captureAssertionViolation\w* = require\(['"]@sentry\/react-native['"]\)\.captureAssertionViolation/, + ); + // `cond || report()` — truthy short-circuits, falsy reports. + expect(out).toMatch(/total >= 0 \|\| _captureAssertionViolation\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 (root-relative when the project root is known)', () => { + const out = transform(`invariant(ok);`, { filename: '/proj/src/Foo.tsx', root: '/proj' }); + expect(out).toContain(`siteId: "src/Foo.tsx:1:0"`); + }); + + it('falls back to parentDir/basename for the siteId when the file is outside root', () => { + // No matching root → the path can't be made relative, but a bare basename + // collides across the many `index.tsx` files an app has. The last two + // segments keep it disambiguated. + const out = transform(`invariant(ok);`, { filename: '/proj/src/Foo.tsx' }); + expect(out).toContain(`siteId: "src/Foo.tsx:1:0"`); + }); + + it('does not collide siteIds across files sharing a basename', () => { + // `screens/index.tsx` and `components/index.tsx` with an assertion at the + // same line:column must produce distinct siteIds, or the runtime dedup set + // (keyed by siteId) suppresses the second file's violation entirely. + const a = transform(`invariant(ok);`, { filename: '/app/src/screens/index.tsx', root: '/app' }); + const b = transform(`invariant(ok);`, { filename: '/app/src/components/index.tsx', root: '/app' }); + expect(a).toContain(`siteId: "src/screens/index.tsx:1:0"`); + expect(b).toContain(`siteId: "src/components/index.tsx:1:0"`); + expect(a).not.toEqual(b); + }); + + it('constructs the Error at the call site so its stack top is the assertion site', () => { + // The `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. It goes through a + // hoisted global-`Error` alias so a call-site shadow can't break it. + const out = transform(`invariant(total >= 0, 'bad total');`); + expect(out).toMatch(/var _Error\d* = typeof globalThis !== ["']undefined["'] \? globalThis\.Error : Error;/); + expect(out).toMatch(/error: new _Error\d*\(\)/); + }); + + it('is immune to a call-site `Error` shadow (hoisted alias captures the global)', () => { + // A parameter named `Error` shadows the global at the call site. The hoisted + // alias at program top captured the real constructor first, so the injected + // `new _Error()` never resolves to the shadow (which would throw + // `TypeError: Error is not a constructor` when the assertion fires). + const out = transform(`function f(Error) {\n invariant(ok);\n}`); + expect(out).toMatch(/var _Error\d* = typeof globalThis !== ["']undefined["'] \? globalThis\.Error : Error;/); + expect(out).toMatch(/error: new _Error\d*\(\)/); + }); + + it('is immune to a module-level `Error` shadow (alias reads globalThis.Error)', () => { + // A module-scope `const Error` would put the bare `Error` identifier in its + // TDZ at program top, so `var _Error = Error;` would throw at load time. The + // alias reads `globalThis.Error` instead, which the lexical shadow can't + // capture. + const out = transform(`const Error = 1;\ninvariant(ok);`); + expect(out).toMatch(/var _Error\d* = typeof globalThis !== ["']undefined["'] \? globalThis\.Error : Error;/); + expect(out).toMatch(/error: new _Error\d*\(\)/); + }); + + it('emits a `__proto__` value as a computed key, not a prototype setter', () => { + // `{ __proto__: v }` sets the prototype and throws for a non-object value; + // the computed form `{ ['__proto__']: v }` keeps it an own data property. + const out = transform(`const __proto__ = 1;\ninvariant(__proto__ > 0);`); + expect(out).toMatch(/\[["']__proto__["']\]: __proto__/); + expect(out).not.toMatch(/\{\s*__proto__: __proto__/); + }); + + it('forwards variadic substitution args as messageArgs for interpolation', () => { + // RN's Dimensions invariant is `invariant(dims, 'No dimension set for key %s', + // dimension)` — the extra arg must reach the reporter so `%s` interpolates. + const out = transform(`invariant(ok, 'No dimension set for key %s', dimension);`); + expect(out).toContain(`message: 'No dimension set for key %s'`); + expect(out).toMatch(/messageArgs: \[dimension\]/); + }); + + it('omits messageArgs when the pragma has no args past the message', () => { + expect(transform(`invariant(ok, 'bad');`)).not.toContain('messageArgs'); + // ...and when there is no message at all. + expect(transform(`invariant(ok);`)).not.toContain('messageArgs'); + }); + + 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('excludes identifiers bound inside the condition (nested-scope params)', () => { + // `x` is the arrow parameter — bound inside the condition, so it is out of + // scope at the call site where `values` is emitted. Capturing it would throw + // a ReferenceError on the (falsy) report path. `items` (call-site scope) is + // kept; `x` is dropped. + const out = transform(`const items = [];\ninvariant(items.every(x => x > 0));`); + expect(out).toMatch(/values: \{[^}]*\bitems: items\b/); + expect(out).not.toMatch(/\bx: x\b/); + }); + + it('drops a `let` identifier declared after the call site to avoid a TDZ crash', () => { + // `x` is a `let` declared textually below the assertion. On a falsy `cond` + // the original short-circuits and never reads `x`, but the emitted `values` + // would — hitting its temporal dead zone and throwing a ReferenceError on + // the report path. `items` (declared above) is kept; `x` is dropped. + const out = transform(`const items = [];\ninvariant(items.length > 0 && x > 0);\nlet x = 5;`); + expect(out).toMatch(/values: \{[^}]*\bitems: items\b/); + expect(out).not.toMatch(/\bx: x\b/); + }); + + it('keeps a call-site identifier shadowed by a nested param of the same name', () => { + // Outer `items` is referenced as the receiver; the inner `items` param is a + // different binding. Only the call-site binding is safe to emit. + const out = transform(`const items = [];\ninvariant(items.every(items => items > 0));`); + expect(out).toMatch(/values: \{[^}]*\bitems: items\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 \|\| _captureAssertionViolation/); + }); + + 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['"]\)\.captureAssertionViolation/g)?.length ?? 0; + expect(bindings).toBe(1); + const reports = out.match(/_captureAssertionViolation\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['"]\)\.captureAssertionViolation/); + expect(out).not.toMatch(/^\s*import\b/m); + }); + + it('leaves a pragma call used as a subexpression untouched', () => { + // Rewriting `warning(cond) && next()` to `(cond || report()) && next()` + // would change the branching (report() is truthy where `warning` returned + // undefined), so only standalone assertion statements are instrumented. + const andOut = transform(`warning(cond) && next();`); + expect(andOut).not.toContain('_captureAssertionViolation'); + expect(andOut).toMatch(/warning\(cond\) && next\(\)/); + + // Same for an assignment / return position. + const assignOut = transform(`const ok = invariant(cond);`); + expect(assignOut).not.toContain('_captureAssertionViolation'); + }); + + 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('_captureAssertionViolation'); + }); + + it('skips calls with no arguments or a leading spread', () => { + const out = transform(`invariant();\ninvariant(...args);`); + expect(out).not.toContain('_captureAssertionViolation'); + }); + + 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(/_captureAssertionViolation\w*\(\{/g)?.length ?? 0; + const bindings = second.match(/require\(['"]@sentry\/react-native['"]\)\.captureAssertionViolation/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 a first-party file whose path merely contains the node_modules substring', () => { + // `node_modules` must match a real path segment, not a raw substring — a + // first-party file named like `node_modules_helper.ts` is not a dependency + // and must still be instrumented. + const out = transform(`invariant(ok);`, { filename: '/proj/src/utils/node_modules_helper.ts' }); + expect(out).toContain('@sentry/react-native'); + expect(out).toContain(`pragma: "invariant"`); + }); + + 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('does not crash the build on a non-array `pragmas`/`rethrowPragmas` option', () => { + // Metro config is untyped at runtime; a bare string (or any non-array) must + // fall back to the defaults instead of throwing a `TypeError` at `.includes`. + const badPragmas = { options: { pragmas: 'invariant' } as unknown as SentryAssertionBabelPluginOptions }; + expect(() => transform(`invariant(ok);`, badPragmas)).not.toThrow(); + // Falls back to DEFAULT_PRAGMAS, so `invariant` is still instrumented. + expect(transform(`invariant(ok);`, badPragmas)).toContain(`pragma: "invariant"`); + + const badRethrow = { options: { rethrowPragmas: 'invariant' } as unknown as SentryAssertionBabelPluginOptions }; + expect(() => transform(`invariant(ok);`, badRethrow)).not.toThrow(); + // Falls back to DEFAULT_RETHROW_PRAGMAS, so `invariant` still re-throws. + expect(transform(`invariant(ok);`, badRethrow)).toContain('rethrow: true'); + }); + + 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('_captureAssertionViolation'); + expect(out).toMatch(/invariant\(ok\)/); + }); + + it('never instruments the Sentry SDK’s own source on Windows-style paths', () => { + // Babel hands filenames with backslashes on Windows; the SDK markers are + // written with forward slashes. Without normalizing the path first the + // marker never matches and the SDK gets a self-referential reporter + // injected into its own source. + const out = transform(`import invariant from 'invariant';\ninvariant(ok);`, { + filename: 'C:\\proj\\node_modules\\@sentry\\react-native\\dist\\js\\foo.js', + options: { includeNodeModules: true }, + }); + expect(out).not.toContain('_captureAssertionViolation'); + expect(out).toMatch(/invariant\(ok\)/); + }); + + it('never instruments `@sentry-internal` packages (SDK transitive deps)', () => { + // `@sentry-internal/*` packages are dependencies of `@sentry/react-native`; + // instrumenting them injects a require of the SDK into its own dependency + // graph, creating a circular require that can leave the reporter undefined. + const out = transform(`import invariant from 'invariant';\ninvariant(ok);`, { + filename: '/proj/node_modules/@sentry-internal/browser-utils/index.js', + options: { includeNodeModules: true }, + }); + expect(out).not.toContain('_captureAssertionViolation'); + 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('_captureAssertionViolation'); + }); + + 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('_captureAssertionViolation'); + }); + + 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('_captureAssertionViolation'); + }); + + it('injects a call to the runtime reporter that `@sentry/react-native` actually exports', () => { + // The plugin emits `require('@sentry/react-native').`. If the + // runtime export is renamed without updating `CAPTURE_FN` (or vice versa), + // the injected helper resolves to `undefined` and every instrumented + // assertion throws at runtime — a silent, build-time-invisible break. Assert + // the two stay coupled. + expect(typeof captureAssertionViolation).toBe('function'); + expect(captureAssertionViolation.name).toBe(CAPTURE_FN); + expect(transform(`invariant(ok);`)).toContain(`.${CAPTURE_FN}`); + }); +}); diff --git a/packages/core/test/tools/sentryBabelTransformer.test.ts b/packages/core/test/tools/sentryBabelTransformer.test.ts index 361e49575b..bf489f5a30 100644 --- a/packages/core/test/tools/sentryBabelTransformer.test.ts +++ b/packages/core/test/tools/sentryBabelTransformer.test.ts @@ -178,6 +178,112 @@ describe('SentryBabelTransformer', () => { ); }); + test('transform adds the capture assertions plugin with its options', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + captureAssertions: { pragmas: ['invariant', 'assert'] }, + }); + + createSentryBabelTransformer().transform?.(createMinimalMockedTransformOptions()); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: expect.arrayContaining([ + [expect.objectContaining({ name: 'sentryAssertionBabelPlugin' }), { pragmas: ['invariant', 'assert'] }], + ]), + }), + ); + }); + + test('transform does not add the capture assertions plugin for node_modules by default', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ captureAssertions: {} }); + + 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: 'sentryAssertionBabelPlugin' }), expect.anything()]]), + ); + }); + + test('transform adds the capture assertions plugin for node_modules when includeNodeModules is set', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + captureAssertions: { 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: 'sentryAssertionBabelPlugin' }), { includeNodeModules: true }], + ]), + }), + ); + }); + + test('transform honors an includeNodeModules array allowlist for node_modules', () => { + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + captureAssertions: { 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: 'sentryAssertionBabelPlugin' }), 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: 'sentryAssertionBabelPlugin' }), + { includeNodeModules: ['react-native/Libraries/Utilities'] }, + ], + ]), + ); + }); + + test('transform honors an includeNodeModules array allowlist on Windows-style paths', () => { + // Babel/Metro pass backslash filenames on Windows; the allowlist fragments + // are written with forward slashes. The gate must normalize before matching, + // or an allowlisted dependency is silently skipped on Windows. + process.env[SENTRY_BABEL_TRANSFORMER_OPTIONS] = JSON.stringify({ + captureAssertions: { includeNodeModules: ['react-native/Libraries/Utilities'] }, + }); + + createSentryBabelTransformer().transform?.({ + ...createMinimalMockedTransformOptions(), + filename: 'C:\\project\\node_modules\\react-native\\Libraries\\Utilities\\Dimensions.js', + }); + const includedArgs = MockDefaultBabelTransformer.transform.mock.calls[0][0] as BabelTransformerArgs; + expect(includedArgs.plugins).toEqual( + expect.arrayContaining([ + [ + expect.objectContaining({ name: 'sentryAssertionBabelPlugin' }), + { includeNodeModules: ['react-native/Libraries/Utilities'] }, + ], + ]), + ); + }); + test.each([ [ { diff --git a/samples/react-native/metro.config.js b/samples/react-native/metro.config.js index bb5a25d8e5..29e5a041d8 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'], }, + // Assertion capture 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')`) is captured with no source + // changes — without blanket-instrumenting all of node_modules. + captureAssertions: { + 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..b281bb74b0 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')); }} /> +