diff --git a/CHANGELOG.md b/CHANGELOG.md index d9de6dce32..4cd60015cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Capture errors that hit Expo Router's per-route `ErrorBoundary`. Wrap the boundary with `Sentry.wrapRouterErrorBoundary(ErrorBoundary)` in your route file to capture render-phase errors with route context (`route.name`, `route.path`, `route.params`), tag the in-flight navigation transaction as errored, and emit a breadcrumb. Concrete paths and params are gated behind `sendDefaultPii` ([#6160](https://github.com/getsentry/sentry-react-native/issues/6160)) - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index adccaaaa7b..515848eac0 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -285,6 +285,14 @@ export interface ExpoRouter { replace?: (...args: unknown[]) => void; } +// @public +export interface ExpoRouterErrorBoundaryProps { + // (undocumented) + error: Error; + // (undocumented) + retry: () => Promise; +} + // Warning: (ae-forgotten-export) The symbol "ExpoRouterIntegrationOptions" needs to be exported by the entry point index.d.ts // // @public @@ -890,6 +898,9 @@ export function wrapExpoImage(imageClass: T): T; // @public export function wrapExpoRouter(router: T): T; +// @public +export function wrapRouterErrorBoundary

(OriginalErrorBoundary: React_2.ComponentType

): React_2.ComponentType

; + // @public export function wrapTurboModule(name: string, module: T | null | undefined, options?: { skip?: ReadonlyArray; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 1adbd21b07..4e639adc21 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -134,11 +134,12 @@ export { createTimeToInitialDisplay, wrapExpoRouter, expoRouterIntegration, + wrapRouterErrorBoundary, wrapExpoImage, wrapExpoAsset, } from './tracing'; -export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset } from './tracing'; +export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; diff --git a/packages/core/src/js/tracing/expoRouterErrorBoundary.tsx b/packages/core/src/js/tracing/expoRouterErrorBoundary.tsx new file mode 100644 index 0000000000..4d6d3047e2 --- /dev/null +++ b/packages/core/src/js/tracing/expoRouterErrorBoundary.tsx @@ -0,0 +1,127 @@ +import type { Scope } from '@sentry/core'; + +import { + addBreadcrumb, + addExceptionMechanism, + captureException, + getActiveSpan, + getClient, + getRootSpan, + SPAN_STATUS_ERROR, +} from '@sentry/core'; +import * as React from 'react'; + +import { getCurrentExpoRouterRouteInfo } from './expoRouterStore'; + +/** + * The minimal shape of Expo Router's per-route `ErrorBoundary` props. + * + * We re-declare it here to avoid a hard dependency on `expo-router`. + */ +export interface ExpoRouterErrorBoundaryProps { + error: Error; + retry: () => Promise; +} + +/** + * Wraps Expo Router's per-route `ErrorBoundary` so that the SDK captures + * errors that hit the boundary instead of relying on the user's global error + * handler. + * + * Expo Router renders the boundary exported from a route file + * (`export { ErrorBoundary } from 'expo-router'`) when a component throws + * during render. Without this wrapper, Sentry only sees the error if it also + * reaches `ErrorUtils` — which it often does not, because React swallows the + * error once a boundary handles it. + * + * For each new `error` instance the wrapper: + * - Captures the error to Sentry with `route.name`, `route.path`, and + * `route.params` attached, gated by `sendDefaultPii` for concrete fields. + * - Tags the active idle navigation span (and its root) with + * `SPAN_STATUS_ERROR` so the navigation transaction reflects the failure. + * - Adds a breadcrumb describing the boundary render. + * + * @example + * ```ts + * // app/_layout.tsx\n * import { ErrorBoundary as ExpoErrorBoundary } from 'expo-router';\n * import * as Sentry from '@sentry/react-native';\n *\n * export const ErrorBoundary = Sentry.wrapRouterErrorBoundary(ExpoErrorBoundary);\n * ```\n */ +export function wrapRouterErrorBoundary

( + OriginalErrorBoundary: React.ComponentType

, +): React.ComponentType

{ + const Wrapped: React.FC

= props => { + // Track the last error instance we reported so a re-render with the same + // error does not produce a duplicate event. Resets when `retry()` clears + // the error and React unmounts the boundary. + const reportedErrorRef = React.useRef(null); + + if (reportedErrorRef.current !== props.error && props.error) { + reportedErrorRef.current = props.error; + reportRouterBoundaryError(props.error); + } + + return ; + }; + + Wrapped.displayName = `wrapRouterErrorBoundary(${ + OriginalErrorBoundary.displayName || OriginalErrorBoundary.name || 'ErrorBoundary' + })`; + + return Wrapped as React.ComponentType

; +} + +function reportRouterBoundaryError(error: Error): void { + const sendPii = getClient()?.getOptions()?.sendDefaultPii ?? false; + const route = getCurrentExpoRouterRouteInfo(); + + const templatedPath = route?.templatedPath; + // `templatedPath` (e.g. `/users/[id]`) is structural and safe; concrete path + // (e.g. `/users/42`) and `params` may contain identifiers and are PII-gated. + const routeName = templatedPath ?? 'unknown'; + const concretePath = sendPii ? (route?.pathnameWithParams ?? route?.pathname) : undefined; + + addBreadcrumb({ + category: 'expo-router.error_boundary', + type: 'error', + level: 'error', + message: `Expo Router ErrorBoundary rendered for ${routeName}`, + data: { + 'route.name': routeName, + ...(concretePath ? { 'route.path': concretePath } : undefined), + }, + }); + + markActiveNavigationSpanErrored(); + + captureException(error, (scope: Scope) => { + scope.setTag('expo_router.error_boundary', 'true'); + scope.setContext('route', { + name: routeName, + ...(concretePath ? { path: concretePath } : undefined), + ...(sendPii && route?.params ? { params: route.params } : undefined), + ...(route?.segments ? { segments: route.segments } : undefined), + }); + scope.addEventProcessor(event => { + addExceptionMechanism(event, { type: 'expo_router_error_boundary', handled: true }); + return event; + }); + return scope; + }); +} + +/** + * If an idle navigation span (or any child) is still open when the boundary + * renders, mark its root as errored so the resulting transaction reflects the + * navigation failure. Scoped to navigation roots so that a user-started + * custom span is not retroactively flipped to errored. No-op otherwise. + */ +function markActiveNavigationSpanErrored(): void { + const active = getActiveSpan(); + if (!active) { + return; + } + const root = getRootSpan(active); + const origin = (root as { attributes?: Record })?.attributes?.['sentry.origin']; + if (typeof origin !== 'string' || !origin.startsWith('auto.navigation.')) { + return; + } + root.setStatus({ code: SPAN_STATUS_ERROR, message: 'expo_router_error_boundary' }); +} diff --git a/packages/core/src/js/tracing/expoRouterIntegration.ts b/packages/core/src/js/tracing/expoRouterIntegration.ts index 4f777ef14a..58df6f0056 100644 --- a/packages/core/src/js/tracing/expoRouterIntegration.ts +++ b/packages/core/src/js/tracing/expoRouterIntegration.ts @@ -2,32 +2,19 @@ import type { Client, Integration } from '@sentry/core'; import { debug } from '@sentry/core'; +import type { ExpoRouterStore, ExpoRouterUrlObject } from './expoRouterStore'; import type { RouteOverride } from './reactnavigation'; +import { buildExpoRouterTemplatedPath, tryGetExpoRouterStore } from './expoRouterStore'; import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation'; +export { buildExpoRouterTemplatedPath }; + export const INTEGRATION_NAME = 'ExpoRouter'; const POLL_INTERVAL_MS = 50; const POLL_MAX_DURATION_MS = 5_000; -interface ExpoRouterNavigationRef { - current: unknown | null; -} - -interface ExpoRouterUrlObject { - unstable_globalHref?: string; - pathname?: string; - pathnameWithParams?: string; - params?: Record; - segments?: string[]; -} - -interface ExpoRouterStore { - navigationRef?: ExpoRouterNavigationRef; - getRouteInfo?: () => ExpoRouterUrlObject; -} - type ExpoRouterIntegrationOptions = Parameters[0]; /** @@ -108,34 +95,6 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} }; }; -function tryGetExpoRouterStore(): ExpoRouterStore | null { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const mod = require('expo-router/build/global-state/router-store') as { - store?: ExpoRouterStore; - }; - return mod?.store ?? null; - } catch { - return null; - } -} - -/** - * Builds a templated pathname from Expo Router's `segments` - * - * Examples: - * ['(tabs)', 'profile', '[id]'] -> '/profile/[id]' - * ['posts', '[...slug]'] -> '/posts/[...slug]' - * [] -> '/' - */ -export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string { - if (!segments || segments.length === 0) { - return '/'; - } - const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')'))); - return filtered.length === 0 ? '/' : `/${filtered.join('/')}`; -} - function buildExpoRouterRouteOverride(store: ExpoRouterStore): RouteOverride | undefined { let info: ExpoRouterUrlObject | undefined; try { diff --git a/packages/core/src/js/tracing/expoRouterStore.ts b/packages/core/src/js/tracing/expoRouterStore.ts new file mode 100644 index 0000000000..438735358f --- /dev/null +++ b/packages/core/src/js/tracing/expoRouterStore.ts @@ -0,0 +1,98 @@ +/** + * Shared helpers for reading Expo Router's internal router store. + * + * Used by: + * - {@link expoRouterIntegration} to attach the current route to the idle + * navigation span via {@link RouteOverride}. + * - {@link wrapRouterErrorBoundary} to attach the current route to errors + * surfaced through Expo Router's per-route `ErrorBoundary`. + */ + +export interface ExpoRouterNavigationRef { + current: unknown | null; +} + +export interface ExpoRouterUrlObject { + unstable_globalHref?: string; + pathname?: string; + pathnameWithParams?: string; + params?: Record; + segments?: string[]; +} + +export interface ExpoRouterStore { + navigationRef?: ExpoRouterNavigationRef; + getRouteInfo?: () => ExpoRouterUrlObject; +} + +export interface NormalizedExpoRouterRouteInfo { + /** + * Templated pathname with grouping segments (`(tabs)`) removed. Safe to send + * regardless of `sendDefaultPii`. Examples: + * ['(tabs)', 'profile', '[id]'] -> '/profile/[id]' + * ['posts', '[...slug]'] -> '/posts/[...slug]' + * [] -> '/' + */ + templatedPath: string; + /** Concrete pathname (may contain user identifiers). Caller decides PII handling. */ + pathname?: string; + /** Concrete pathname including query/params (may contain PII). */ + pathnameWithParams?: string; + params?: Record; + segments?: string[]; +} + +/** + * Returns Expo Router's internal router store, or `null` if `expo-router` is + * not installed or the build does not expose the expected module path. + */ +export function tryGetExpoRouterStore(): ExpoRouterStore | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('expo-router/build/global-state/router-store') as { + store?: ExpoRouterStore; + }; + return mod?.store ?? null; + } catch { + return null; + } +} + +/** + * Builds a templated pathname from Expo Router's `segments`. Grouping segments + * (e.g. `(tabs)`, `(auth)`) are stripped because they do not appear in the URL. + */ +export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string { + if (!segments || segments.length === 0) { + return '/'; + } + const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')'))); + return filtered.length === 0 ? '/' : `/${filtered.join('/')}`; +} + +/** + * Reads the current route from Expo Router's store and normalizes it. Returns + * `undefined` if the store is not reachable or `getRouteInfo` throws. + */ +export function getCurrentExpoRouterRouteInfo(): NormalizedExpoRouterRouteInfo | undefined { + const store = tryGetExpoRouterStore(); + if (!store) { + return undefined; + } + let info: ExpoRouterUrlObject | undefined; + try { + info = store.getRouteInfo?.(); + } catch { + return undefined; + } + if (!info) { + return undefined; + } + return { + templatedPath: buildExpoRouterTemplatedPath(info.segments), + pathname: info.pathname, + pathnameWithParams: info.pathnameWithParams, + params: info.params, + segments: info.segments, + }; +} diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index e8d4ed6ba6..14cf1f8844 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -14,6 +14,9 @@ export type { ExpoRouter } from './expoRouter'; export { expoRouterIntegration } from './expoRouterIntegration'; +export { wrapRouterErrorBoundary } from './expoRouterErrorBoundary'; +export type { ExpoRouterErrorBoundaryProps } from './expoRouterErrorBoundary'; + export { wrapExpoImage } from './expoImage'; export type { ExpoImage } from './expoImage'; diff --git a/packages/core/test/tracing/expoRouterErrorBoundary.test.tsx b/packages/core/test/tracing/expoRouterErrorBoundary.test.tsx new file mode 100644 index 0000000000..60d5f2dc18 --- /dev/null +++ b/packages/core/test/tracing/expoRouterErrorBoundary.test.tsx @@ -0,0 +1,187 @@ +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; + +import { wrapRouterErrorBoundary } from '../../src/js/tracing/expoRouterErrorBoundary'; + +const mockCaptureException = jest.fn(); +const mockAddBreadcrumb = jest.fn(); +const mockAddExceptionMechanism = jest.fn(); +let mockSendDefaultPii = false; +let mockActiveSpan: { setStatus: jest.Mock; attributes: Record } | undefined; + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + captureException: (...args: unknown[]) => mockCaptureException(...args), + addBreadcrumb: (...args: unknown[]) => mockAddBreadcrumb(...args), + addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args), + getClient: () => ({ getOptions: () => ({ sendDefaultPii: mockSendDefaultPii }) }), + getActiveSpan: () => mockActiveSpan, + getRootSpan: (span: unknown) => span, + }; +}); + +const mockRouteInfo = { + templatedPath: '/users/[id]', + pathname: '/users/42', + pathnameWithParams: '/users/42?ref=email', + params: { id: '42', ref: 'email' }, + segments: ['(tabs)', 'users', '[id]'], +}; +let mockRouteInfoValue: typeof mockRouteInfo | undefined = mockRouteInfo; + +jest.mock('../../src/js/tracing/expoRouterStore', () => ({ + getCurrentExpoRouterRouteInfo: () => mockRouteInfoValue, +})); + +const OriginalErrorBoundary: React.FC<{ error: Error; retry: () => Promise }> = ({ error }) => ( + {error.message} +); + +function runScope(): { + tags: Record; + contexts: Record; + processors: ((e: unknown) => unknown)[]; +} { + const tags: Record = {}; + const contexts: Record = {}; + const processors: ((e: unknown) => unknown)[] = []; + const scope = { + setTag: (k: string, v: string) => { + tags[k] = v; + }, + setContext: (k: string, v: unknown) => { + contexts[k] = v; + }, + addEventProcessor: (p: (e: unknown) => unknown) => { + processors.push(p); + }, + }; + const callback = mockCaptureException.mock.calls[0]?.[1]; + callback?.(scope); + return { tags, contexts, processors }; +} + +describe('wrapRouterErrorBoundary', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSendDefaultPii = false; + mockActiveSpan = undefined; + mockRouteInfoValue = mockRouteInfo; + }); + + it('renders the wrapped ErrorBoundary with the original props', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + const { getByTestId } = render(); + expect(getByTestId('fallback').props.children).toBe('boom'); + }); + + it('captures the error to Sentry once per error instance', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + const err = new Error('boom'); + const { rerender } = render(); + rerender(); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException.mock.calls[0][0]).toBe(err); + }); + + it('re-captures when a new error instance arrives', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + const { rerender } = render(); + rerender(); + expect(mockCaptureException).toHaveBeenCalledTimes(2); + }); + + it('attaches route context with templated path only when sendDefaultPii is off', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + const { tags, contexts } = runScope(); + expect(tags['expo_router.error_boundary']).toBe('true'); + expect(contexts.route).toEqual({ + name: '/users/[id]', + segments: ['(tabs)', 'users', '[id]'], + }); + }); + + it('includes concrete path and params when sendDefaultPii is on', () => { + mockSendDefaultPii = true; + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + const { contexts } = runScope(); + expect(contexts.route).toEqual({ + name: '/users/[id]', + path: '/users/42?ref=email', + params: { id: '42', ref: 'email' }, + segments: ['(tabs)', 'users', '[id]'], + }); + }); + + it('adds a breadcrumb with the templated route name', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo-router.error_boundary', + type: 'error', + level: 'error', + message: 'Expo Router ErrorBoundary rendered for /users/[id]', + data: { 'route.name': '/users/[id]' }, + }); + }); + + it('tags the exception with an expo_router_error_boundary mechanism', () => { + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + const { processors } = runScope(); + const event = { exception: { values: [{}] } }; + processors[0]?.(event); + expect(mockAddExceptionMechanism).toHaveBeenCalledWith(event, { + type: 'expo_router_error_boundary', + handled: true, + }); + }); + + it('marks the active navigation span as errored', () => { + mockActiveSpan = { + setStatus: jest.fn(), + attributes: { 'sentry.origin': 'auto.navigation.expo_router' }, + }; + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + expect(mockActiveSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'expo_router_error_boundary', + }); + }); + + it('does not touch user-owned spans (non-navigation origin)', () => { + mockActiveSpan = { + setStatus: jest.fn(), + attributes: { 'sentry.origin': 'manual' }, + }; + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + expect(mockActiveSpan.setStatus).not.toHaveBeenCalled(); + }); + + it('still works when expo-router store is not reachable', () => { + mockRouteInfoValue = undefined; + const Wrapped = wrapRouterErrorBoundary(OriginalErrorBoundary); + render(); + + const { tags, contexts } = runScope(); + expect(tags['expo_router.error_boundary']).toBe('true'); + expect(contexts.route).toEqual({ name: 'unknown' }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Expo Router ErrorBoundary rendered for unknown' }), + ); + }); +}); diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index d7486fedaa..e6cdd2f7e0 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/react-native'; import { isRunningInExpoGo } from 'expo'; import * as ImagePicker from 'expo-image-picker'; -import { SplashScreen, Stack } from 'expo-router'; +import { ErrorBoundary as ExpoErrorBoundary, SplashScreen, Stack } from 'expo-router'; import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router/react-navigation'; import { useEffect } from 'react'; import { LogBox } from 'react-native'; @@ -10,10 +10,9 @@ import { useColorScheme } from '@/components/useColorScheme'; import { SENTRY_INTERNAL_DSN } from '../utils/dsn'; -export { - // Catch any errors thrown by the Layout component. - ErrorBoundary, -} from 'expo-router'; +// Wrap Expo Router's per-route ErrorBoundary so render-phase errors that hit +// the fallback UI are captured by Sentry with route context attached. +export const ErrorBoundary = Sentry.wrapRouterErrorBoundary(ExpoErrorBoundary); LogBox.ignoreAllLogs();