Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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 @@
standalone?: boolean;
}) => AppStartIntegration;

// @public (undocumented)
export interface AssertionViolationOptions {
condition?: string;
error?: Error;
message?: string;
once?: boolean;
pragma?: string;
rethrow?: boolean;
siteId?: string;

Check warning on line 162 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: find-bugs

Destructured console.assert miscompiled as throwing assert

The assertion Babel plugin rewrites `const { assert } = console; assert(false)` with `rethrow: true`, turning a report-only `console.assert` into a throwing assertion that crashes after capturing.
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 @@
// @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 @@
name: string;
};

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

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

Expand Down
226 changes: 226 additions & 0 deletions packages/core/src/js/assertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
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;
}

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

/**

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

View check run for this annotation

@sentry/warden / warden: code-review

String() throws TypeError on Symbol values in flattenValues

`String(symbol)` throws a `TypeError` and crashes the assertion reporter when runtime `values` contains a Symbol.
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)) {
const value = values[key];
data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(String(value), MAX_VALUE_LENGTH);

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

View check run for this annotation

@sentry/warden / warden: find-bugs

flattenValues crashes when captured value has throwing toString

Wrap `String(value)` in a try-catch so a throwing `toString` or `[Symbol.toPrimitive]` doesn't break the reporting path and cause double-capture.
}
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
// 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;
}
if (values !== undefined) {
Object.assign(data, flattenValues(values));
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 @@ -117,62 +117,64 @@
finishExtendedAppStart,
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';
export { GlobalErrorBoundary, withGlobalErrorBoundary } from './GlobalErrorBoundary';
export type { GlobalErrorBoundaryProps } from './GlobalErrorBoundary';

export {
reactNativeTracingIntegration,
getCurrentReactNativeTracingIntegration,
getReactNativeTracingIntegration,
reactNavigationIntegration,
reactNativeNavigationIntegration,
sentryTraceGesture,
TimeToInitialDisplay,
TimeToFullDisplay,
startTimeToInitialDisplaySpan,
startTimeToFullDisplaySpan,
reportFullyDisplayed,
startIdleNavigationSpan,
startIdleSpan,
getDefaultIdleNavigationSpanOptions,
createTimeToFullDisplay,
createTimeToInitialDisplay,
wrapExpoRouter,
expoRouterIntegration,
wrapExpoRouterErrorBoundary,
wrapExpoImage,
wrapExpoAsset,
} from './tracing';

export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing';

export { Mask, Unmask } from './replay/CustomMask';

/** @deprecated The `FeedbackButton` component will be removed in a future major version. */
export { FeedbackButton } from './feedback/FeedbackButton';
export { FeedbackForm } from './feedback/FeedbackForm';
export { showFeedbackForm, enableFeedbackOnShake, disableFeedbackOnShake } from './feedback/FeedbackFormManager';
/** @deprecated `showFeedbackButton` will be removed in a future major version. */
export { showFeedbackButton } from './feedback/FeedbackFormManager';
/** @deprecated `hideFeedbackButton` will be removed in a future major version. */
export { hideFeedbackButton } from './feedback/FeedbackFormManager';

/** @deprecated Use `FeedbackForm` instead. */
export { FeedbackForm as FeedbackWidget } from './feedback/FeedbackForm';
/** @deprecated Use `showFeedbackForm` instead. */
export { showFeedbackForm as showFeedbackWidget } from './feedback/FeedbackFormManager';

export { getDataFromUri } from './wrapper';

export {
getActiveTurboModuleCall,
getTurboModuleCallStack,
popTurboModuleCall,
pushTurboModuleCall,
wrapTurboModule,
} from './turbomodule';
export type { TurboModuleArch, TurboModuleCall, TurboModuleCallKind } from './turbomodule';
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
30 changes: 27 additions & 3 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -119,6 +133,7 @@ export function withSentryConfig(
enableSourceContextInDevelopment = true,
optionsFile = true,
autoWrapExpoRouterErrorBoundary = false,
captureAssertions = false,
}: SentryMetroConfigOptions = {},
): MetroConfig {
setSentryMetroDevServerEnvFlag();
Expand All @@ -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);
Expand Down Expand Up @@ -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,
);
}

Expand Down Expand Up @@ -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);
Expand All @@ -247,6 +270,7 @@ export function withSentryBabelTransformer(
? { annotateReactComponents: typeof annotateReactComponents === 'object' ? annotateReactComponents : {} }
: {}),
autoWrapExpoRouterErrorBoundary,
...(captureAssertions ? { captureAssertions: typeof captureAssertions === 'object' ? captureAssertions : {} } : {}),
});

return {
Expand Down
Loading
Loading