Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 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,18 @@ export const appStartIntegration: (input?: {
standalone?: boolean;
}) => AppStartIntegration;

// @public (undocumented)
export interface AssertionViolationOptions {
condition?: string;
error?: Error;
message?: string;
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 +182,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 +238,9 @@ export const deeplinkIntegration: (...args: any[]) => Integration & {
name: string;
};

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

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

Expand Down
241 changes: 241 additions & 0 deletions packages/core/src/js/assertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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>`.
*/
message?: string;
/**
* An already-constructed error carrying the stack of the assertion call site.
* The Babel transform passes a bare `new Error()` created at the call site so
* the stack top is the assertion site itself (in dev and release); its
* message is backfilled from `message`/`condition`. When omitted a synthetic
* error is fabricated so a stack is captured without actually throwing.
*/
error?: Error;
/**
* A stable identifier for the call site (e.g. `"ErrorsScreen.tsx:73:4"`),
* injected by the Babel transform. When provided, the violation is reported
* at most once per site per session to avoid flooding the issue stream from
* an assertion inside a hot loop or a frequently re-rendered component.
*
* Pass `once: false` to opt out and report on every invocation.
*/
siteId?: string;
/**
* Whether to deduplicate by `siteId`. Defaults to `true` when a `siteId` is
* provided. Has no effect without a `siteId`.
*
* @default true
*/
once?: boolean;
/**
* Re-throw the `error` after reporting, preserving the original throwing
* semantics of hard preconditions (`invariant`, `assert`). The Babel transform
* sets this for pragmas listed in its `rethrowPragmas`, so downstream code that
* relied on the assertion halting execution is not reached with invalid state.
*
* The re-throw fires even when the report is deduplicated by `siteId` —
* deduplication suppresses the duplicate *event*, never the control flow. To
* avoid the rethrown error being reported a second time as an unhandled crash,
* the reporter tags it so Sentry's global handler skips it.
*
* Report-only pragmas (`warning`, `console.assert`) leave this `false`.
*
* @default false
*/
rethrow?: boolean;
}

/**
* Call sites already reported this session, keyed by `siteId`. Kept module-level
* so it persists for the lifetime of the JS runtime (i.e. the session).
*/
const reportedSites = new Set<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}]`;
}
}

/**
* 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 } = {};

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

View check run for this annotation

@sentry/warden / warden: find-bugs

flattenValues is not defensive against throwing getters

Accessing values[key] in the loop can trigger a throwing getter, breaking the reporting path's no-throw guarantee.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
for (const key of Object.keys(values)) {
const value = values[key];
data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(stringifyValue(value), MAX_VALUE_LENGTH);
}
try {
data.values = truncate(JSON.stringify(values) ?? 'undefined', MAX_SNAPSHOT_LENGTH);
} catch (_e) {
// Circular or non-serializable values — the flattened entries above still apply.
}
return data;
}

/**
* Reports a violated 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 = options.message ?? (condition ? `Assertion failed: ${condition}` : 'Assertion failed');

const error = options.error ?? new Error(message);
// The Babel transform creates a bare `new Error()` at the call site so its

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

View check run for this annotation

@sentry/warden / warden: find-bugs

[9LT-YKN] `values` guard does not prevent `null`, crashing the reporter path (additional location)

`values !== undefined` on line 201 allows `null` into `flattenValues`, where `Object.keys(null)` throws a `TypeError` and breaks the reporting path. The Babel transform emits only object literals, but manual calls that bypass the TypeScript contract can crash the safe reporter.
// 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;
}

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

View check run for this annotation

@sentry/warden / warden: find-bugs

`values` guard does not prevent `null`, crashing the reporter path

`values !== undefined` on line 201 allows `null` into `flattenValues`, where `Object.keys(null)` throws a `TypeError` and breaks the reporting path. The Babel transform emits only object literals, but manual calls that bypass the TypeScript contract can crash the safe reporter.
if (values !== undefined) {
Object.assign(data, flattenValues(values));

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

View check run for this annotation

@sentry/warden / warden: code-review

values: null crashes the assertion reporter via Object.keys

`values !== undefined` allows `null` through to `flattenValues`, where `Object.keys(null)` throws a `TypeError` and breaks the reporting path.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
}

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 @@ -116,8 +116,10 @@
getExtendedAppStartSpan,
finishExtendedAppStart,
pauseAppHangTracking,
resumeAppHangTracking,

Check warning on line 119 in packages/core/src/js/index.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[9LT-YKN] `values` guard does not prevent `null`, crashing the reporter path (additional location)

`values !== undefined` on line 201 allows `null` into `flattenValues`, where `Object.keys(null)` throws a `TypeError` and breaks the reporting path. The Babel transform emits only object literals, but manual calls that bypass the TypeScript contract can crash the safe reporter.
} 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
13 changes: 13 additions & 0 deletions packages/core/src/js/integrations/reactnativeerrorhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading