Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Add opt-in "Loud Invariants" Metro transform that reports violated `invariant`/`assert`/`warning`/`console.assert` assertions as non-fatal Sentry events instead of crashing or being stripped ([#6592](https://github.com/getsentry/sentry-react-native/pull/6592))

## 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 @@ -176,6 +176,9 @@ export { captureException }

export { captureFeedback }

// @public
export function captureInvariantViolation(options?: InvariantViolationOptions): string;

export { captureMessage }

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

// @public
export const DEFAULT_INVARIANT_MECHANISM = "invariant";

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

Expand Down Expand Up @@ -473,6 +479,18 @@ export { instrumentStateGraph }

export { instrumentStateGraphCompile }

// @public (undocumented)
export interface InvariantViolationOptions {
condition?: string;
error?: Error;
message?: string;
once?: boolean;
pragma?: string;
rethrow?: boolean;
siteId?: string;
values?: Record<string, unknown>;
}

export { LangChainIntegration }

export { LangChainOptions }
Expand Down
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 { captureInvariantViolation, DEFAULT_INVARIANT_MECHANISM } from './invariant';
export type { InvariantViolationOptions } from './invariant';
export { TouchEventBoundary, withTouchEventBoundary } from './touchevents';
export { NavigationContainer } from './NavigationContainer';
export type { FontStyle, NavigationTheme, SentryNavigationContainerProps } from './NavigationContainer';
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 "loud
// invariant" that reports a handled event before re-throwing, or user code
// doing `captureException(e); throw e;`). `client.captureException` dedups
// via this same `__sentry_captured__` flag, but this handler reports through
// `eventFromException` + `captureEvent`, which don't โ€” so without this guard
// the error is reported a second time as an unhandled crash. Let the default
// handler still run (redbox in dev, teardown in prod).
// oxlint-disable-next-line typescript-eslint(no-unsafe-member-access)
if (error?.__sentry_captured__) {
defaultHandler(error, isFatal);
return;
}

// We want to handle fatals, but only in production mode.
const shouldHandleFatal = isFatal && !__DEV__;
if (shouldHandleFatal) {
Expand Down
216 changes: 216 additions & 0 deletions packages/core/src/js/invariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { addNonEnumerableProperty, captureException, withScope } from '@sentry/core';

import { createSyntheticError, isErrorLike } from './utils/error';

/**
* Default mechanism type reported for a violated invariant.
*
* This is a de-facto (unregistered) mechanism type โ€” Sentry ingestion accepts
* arbitrary `mechanism.type` values and converts them to a tag, so no backend
* or Relay registration is required. Violations render as non-fatal, handled
* events with a full stack trace, breadcrumbs, and Session Replay attached.
*/
export const DEFAULT_INVARIANT_MECHANISM = 'invariant';

export interface InvariantViolationOptions {
/**
* The source text of the assertion condition that failed, e.g. `"total >= 0"`.
* Surfaced under `mechanism.data.condition` and used to build the default message.
*/
condition?: string;
/**
* Runtime values that violated the invariant, e.g. `{ total: -4 }`.
*
* `mechanism.data` only accepts flat `string | boolean` values, so each entry
* is flattened to `values.<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`, ...). Becomes `mechanism.type`.
*
* @default 'invariant'
*/
pragma?: string;
/**
* Human-readable message. Defaults to `Invariant violated: <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);
throw error;
}

/**
* Flattens a runtime values object into the flat `string | boolean` map that
* `mechanism.data` accepts. Nested/complex values are stringified. Both the
* per-key entries and the JSON snapshot are length-capped so a large captured
* object (e.g. a whole config or dimensions map) can't bloat the event payload.
*/
function flattenValues(values: Record<string, unknown>): { [key: string]: string | boolean } {
const data: { [key: string]: string | boolean } = {};
for (const key of Object.keys(values)) {
const value = values[key];
data[`values.${key}`] = typeof value === 'boolean' ? value : truncate(String(value), MAX_VALUE_LENGTH);
}
try {
data.values = truncate(JSON.stringify(values) ?? 'undefined', MAX_SNAPSHOT_LENGTH);
} catch (_e) {
// Circular or non-serializable values โ€” the flattened entries above still apply.
}
return data;
}

/**
* Reports a violated invariant to Sentry as a non-fatal (handled) event without
* throwing or crashing the app.
*
* This is the runtime target of the `@sentry/babel-plugin-invariant` transform:
* the plugin rewrites `invariant()` / `assert()` / `console.assert()` /
* `warning()` call sites so that a falsy condition invokes this reporter instead
* of being stripped from the release bundle.
*
* It can also be called by hand (Milestone 0) to de-risk the reporting path.
*
* @returns the id of the captured Sentry event.
*/
export function captureInvariantViolation(options: InvariantViolationOptions = {}): string {
const { condition, values, pragma = DEFAULT_INVARIANT_MECHANISM, siteId, once = true, rethrow = false } = options;

const message = options.message ?? (condition ? `Invariant violated: ${condition}` : 'Invariant violated');

const error = options.error ?? new Error(message);
// The Babel transform creates a bare `new Error()` at the call site so its
// stack top is the assertion site; backfill the readable message here, in the
// one place that owns the default-message template.
if (!error.message) {
error.message = message;
}

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

const data: { [key: string]: string | boolean } = {};
if (condition !== undefined) {
data.condition = condition;
}
if (siteId !== undefined) {
data.siteId = siteId;
}
if (values !== undefined) {
Object.assign(data, flattenValues(values));
}

const eventId = withScope(scope => {
// Group deterministically by call site rather than by the runtime stack top.
// For an inline assertion the top frame is a generic host frame (e.g. React
// Native's Pressability internals) shared by every violation, so default
// stack-based grouping would collapse unrelated invariants into one issue.
// The build-time `siteId` is stable across dev and release; fall back to the
// condition (then message) for hand-written calls that carry no `siteId`.
scope.setFingerprint(['loud-invariant', pragma, siteId ?? condition ?? message]);

return captureException(error, {
// `synthetic: true` โ€” the error was fabricated to carry a stack, not thrown.
// `handled: true` โ€” renders as a non-fatal in the issue stream.
mechanism: {
type: pragma,
handled: true,
synthetic: true,
data,
},
// When the error carries no usable stack, attach a synthetic one so the
// event still has a stack trace pointing near the call site.
syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
});
});

if (rethrow) {
// Preserve the precondition's throwing semantics: re-throw after capturing so
// downstream code that assumed the precondition held is not reached with
// invalid state. `rethrowCaptured` tags the error so the global error handler
// skips it โ€” otherwise the same violation is reported twice (once handled
// here, once as an unhandled crash).
rethrowCaptured(error);
}

return eventId;
}
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 { SentryInvariantBabelPluginOptions } from './sentryInvariantBabelPlugin';
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`,
* `console.assert`) so a violated invariant is reported to Sentry as a
* non-fatal (handled) event instead of being stripped from the release
* bundle or crashing the app.
*
* Pass `true` to instrument first-party code with the default pragma set, or
* an object to customize the pragmas and to opt into instrumenting
* `node_modules`.
*
* @default false
*/
loudInvariants?: boolean | SentryInvariantBabelPluginOptions;
}

export interface SentryExpoConfigOptions {
Expand Down Expand Up @@ -119,6 +133,7 @@ export function withSentryConfig(
enableSourceContextInDevelopment = true,
optionsFile = true,
autoWrapExpoRouterErrorBoundary = false,
loudInvariants = 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 || loudInvariants) {
newConfig = withSentryBabelTransformer(
newConfig,
annotateReactComponents,
autoWrapExpoRouterErrorBoundary,
loudInvariants,
);
}
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 loudInvariants = options.loudInvariants ?? false;
if (options.annotateReactComponents || autoWrapExpoRouterErrorBoundary || loudInvariants) {
newConfig = withSentryBabelTransformer(
newConfig,
options.annotateReactComponents ?? false,
autoWrapExpoRouterErrorBoundary,
loudInvariants,
);
}

Expand Down Expand Up @@ -225,6 +247,7 @@ export function withSentryBabelTransformer(
| boolean
| { ignoredComponents?: string[]; autoInjectSentryLabel?: boolean; textComponentNames?: string[] },
autoWrapExpoRouterErrorBoundary: boolean = false,
loudInvariants: SentryMetroConfigOptions['loudInvariants'] = false,
): MetroConfig {
const defaultBabelTransformerPath = config.transformer?.babelTransformerPath;
debug.log('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath);
Expand All @@ -247,6 +270,7 @@ export function withSentryBabelTransformer(
? { annotateReactComponents: typeof annotateReactComponents === 'object' ? annotateReactComponents : {} }
: {}),
autoWrapExpoRouterErrorBoundary,
...(loudInvariants ? { loudInvariants: typeof loudInvariants === 'object' ? loudInvariants : {} } : {}),
});

return {
Expand Down
Loading
Loading