Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## 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
Expand Down
19 changes: 19 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
values?: Record<string, unknown>;
}

export { Breadcrumb }

// Warning: (ae-forgotten-export) The symbol "BreadcrumbsOptions" needs to be exported by the entry point index.d.ts
Expand All @@ -170,6 +183,9 @@ export { browserLinkedErrorsIntegration }
// @public
export const browserReplayIntegration: (options?: ReplayConfiguration) => Replay;

// @public
export function captureAssertionViolation(options?: AssertionViolationOptions): string;

export { captureEvent }

export { captureException }
Expand Down Expand Up @@ -223,6 +239,9 @@ export const deeplinkIntegration: (...args: any[]) => Integration & {
name: string;
};

// @public
export const DEFAULT_ASSERTION_MECHANISM = "assertion";

// @public
export const deviceContextIntegration: () => Integration;

Expand Down
323 changes: 323 additions & 0 deletions packages/core/src/js/assertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
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.<key>` and stringified. The full object is also
* preserved as a JSON snapshot under `values`.
*/
values?: Record<string, unknown>;
/**
* 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: <condition>`. 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<string>();

/** Max length of a single flattened `values.<key>` string before truncation. */
const MAX_VALUE_LENGTH = 256;
/** Max length of the whole `values` JSON snapshot before truncation. */
const MAX_SNAPSHOT_LENGTH = 1024;

/** Truncates `text` to `max` characters, appending an ellipsis marker if cut. */
function truncate(text: string, max: number): string {
return text.length > max ? `${text.slice(0, max)}…[truncated]` : text;
}

/**
* 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 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(Number(arg)));
case '%f':
return String(Number(arg));

Check failure on line 162 in packages/core/src/js/assertion.ts

View check run for this annotation

@sentry/warden / warden: code-review

Unprotected Number coercion in formatMessage can throw TypeError on the reporting path

`Number(arg)` in `%d`/`%i`/`%f` branches throws for Symbols and BigInts, crashing the assertion reporting path that is documented to never throw. Wrap these conversions in try-catch like the adjacent `%j` branch.

Check warning on line 162 in packages/core/src/js/assertion.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

formatMessage crashes reporting path on non-coercible format args

The %d, %i, and %f format specifiers call Number(arg) without a try-catch, so a Symbol, BigInt, or object with a throwing valueOf crashes the reporting path instead of reporting safely.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
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: <condition>`, 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<string, unknown>, '__sentry_captured__', true);
Comment on lines +209 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rethrowCaptured can throw wrong error for frozen errors, bypassing dedup guard

rethrowCaptured lacks a try-catch around addNonEnumerableProperty, so a frozen or sealed error causes a TypeError instead of the original error and skips the __sentry_captured__ tag. If the dedup path also reaches rethrowCaptured for a frozen error, the same conclusion applies.

Evidence
  • rethrowCaptured does addNonEnumerableProperty(error, '__sentry_captured__', true) then throw error with no surrounding try-catch.
  • addNonEnumerableProperty uses Object.defineProperty, which throws TypeError when adding a new property to a frozen object.
  • The comment above rethrowCaptured explicitly states the tag is set on every rethrow path so the guard can never be bypassed, yet it can be.
  • setupErrorUtilsGlobalHandler in reactnativeerrorhandlers.ts (also changed in this PR) checks error?.__sentry_captured__ to avoid reporting the re-thrown error a second time; if the tag was never set, the violation is reported again as an unhandled crash.

Identified by Warden · find-bugs · T8D-QC8

throw error;
}

/**
Comment thread
sentry-warden[bot] marked this conversation as resolved.
* Flattens a runtime values object into the flat `string | boolean` map that
* `mechanism.data` accepts. Nested/complex values are stringified. Both the
* per-key entries and the JSON snapshot are length-capped so a large captured
* object (e.g. a whole config or dimensions map) can't bloat the event payload.
*/
function flattenValues(values: Record<string, unknown>): { [key: string]: string | boolean } {
const data: { [key: string]: string | boolean } = {};
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
for (const key of Object.keys(values)) {
try {
const value = values[key];
data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(stringifyValue(value), MAX_VALUE_LENGTH);
} catch (_e) {
// A throwing getter must not break the no-throw reporting path.
data[`values.${key}`] = '[unreadable]';
}
}
try {
data.values = truncate(JSON.stringify(values) ?? 'undefined', MAX_SNAPSHOT_LENGTH);
} catch (_e) {
// Circular or non-serializable values — the flattened entries above still apply.

@sentry-warden sentry-warden Bot Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded values iteration in flattenValues allows DoS via large objects

flattenValues iterates over every key in values without a count cap and calls JSON.stringify(values) on the full object before truncation, allowing a large input object to exhaust CPU and memory.

Evidence
  • flattenValues calls Object.keys(values) and loops over every key, creating one data entry per key with no limit on the total number of entries.
  • It then calls JSON.stringify(values) on the entire object before passing the result to truncate, so a large object is fully serialized first.
  • Individual values and snapshots are length-capped (MAX_VALUE_LENGTH and MAX_SNAPSHOT_LENGTH), but the key count is unbounded, contradicting the function's JSDoc claim that a large object "can't bloat the event payload".
  • Because captureAssertionViolation is a public API, a caller can pass an arbitrarily large values object (e.g. a config map or array with millions of indices), causing unbounded CPU and memory consumption on the no-throw reporting path.

Identified by Warden · find-bugs, code-review · CTS-DF4

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. flattenValues now caps the entry count at MAX_VALUE_ENTRIES (50) and builds the JSON snapshot from only that capped subset rather than JSON.stringify-ing the full input, so a large object handed to the public API no longer produces unbounded entries or forces a full serialization on the no-throw path. Over-cap keys are summarized in a values.__truncated__ marker, and the JSDoc is updated to describe all three bounds (count, per-entry length, snapshot length). Added a test with a 1000-key object (162eefb4ed8f2d).

}
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.
*/

Check warning on line 244 in packages/core/src/js/assertion.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[ZPD-B7P] formatMessage crashes reporting path on non-coercible format args (additional location)

The %d, %i, and %f format specifiers call Number(arg) without a try-catch, so a Symbol, BigInt, or object with a throwing valueOf crashes the reporting path instead of reporting safely.
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.
if (!error.message) {
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
error.message = message;
}

// Report each call site at most once per session unless the caller opts out.
// Deduplication only suppresses the duplicate *event* — for a throwing pragma
// the precondition is still violated, so control flow must still be halted.
if (siteId !== undefined && once && reportedSites.has(siteId)) {
if (rethrow) {
rethrowCaptured(error);
}
return '';
}
if (siteId !== undefined && once) {
reportedSites.add(siteId);
}

const data: { [key: string]: string | boolean } = {};
if (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;
}
2 changes: 2 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading