diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd7f038ae..6d394af170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ Sentry.wrapExpoAsset(Asset); ``` - Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788)) +- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events ([#5795](https://github.com/getsentry/sentry-react-native/pull/5795)) + - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs + - Enabled by default in Expo apps (requires `expo-updates` to be installed) + - Automatically capture a warning event when Expo Updates performs an emergency launch ([#5794](https://github.com/getsentry/sentry-react-native/pull/5794)) - Adds environment configuration in the Expo config plugin. This can be set with the `SENTRY_ENVIRONMENT` env variable or in `sentry.options.json` ([#5796](https://github.com/getsentry/sentry-react-native/pull/5796)) ```json diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index a3effed7c0..f4a9bd6599 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -19,6 +19,7 @@ import { eventOriginIntegration, expoConstantsIntegration, expoContextIntegration, + expoUpdatesListenerIntegration, functionToStringIntegration, hermesProfilingIntegration, httpClientIntegration, @@ -133,6 +134,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(expoContextIntegration()); integrations.push(expoConstantsIntegration()); + integrations.push(expoUpdatesListenerIntegration()); if (options.spotlight && __DEV__) { const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index bc228de280..d4e80f8ef6 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { expoConstantsIntegration } from './expoconstants'; +export { expoUpdatesListenerIntegration } from './expoupdateslistener'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; export { feedbackIntegration } from '../feedback/integration'; diff --git a/packages/core/src/js/integrations/expoupdateslistener.ts b/packages/core/src/js/integrations/expoupdateslistener.ts new file mode 100644 index 0000000000..c13de623a1 --- /dev/null +++ b/packages/core/src/js/integrations/expoupdateslistener.ts @@ -0,0 +1,158 @@ +import { addBreadcrumb, debug, type Integration, type SeverityLevel } from '@sentry/core'; +import type { ReactNativeClient } from '../client'; +import { isExpo, isExpoGo } from '../utils/environment'; + +const INTEGRATION_NAME = 'ExpoUpdatesListener'; + +const BREADCRUMB_CATEGORY = 'expo.updates'; + +/** + * Describes the state machine context from `expo-updates`. + * We define our own minimal type to avoid a hard dependency on `expo-updates`. + */ +interface UpdatesNativeStateMachineContext { + isChecking: boolean; + isDownloading: boolean; + isUpdateAvailable: boolean; + isUpdatePending: boolean; + isRestarting: boolean; + latestManifest?: { id?: string }; + downloadedManifest?: { id?: string }; + rollback?: { commitTime: string }; + checkError?: Error; + downloadError?: Error; +} + +interface UpdatesNativeStateChangeEvent { + context: UpdatesNativeStateMachineContext; +} + +/** + * Tries to load `expo-updates` and retrieve `addUpdatesStateChangeListener`. + * Returns `undefined` if `expo-updates` is not installed. + */ +function getAddUpdatesStateChangeListener(): + | ((listener: (event: UpdatesNativeStateChangeEvent) => void) => void) + | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access + const addListener = require('expo-updates').addUpdatesStateChangeListener; + if (typeof addListener === 'function') { + return addListener as (listener: (event: UpdatesNativeStateChangeEvent) => void) => void; + } + } catch (_) { + // that happens when expo-updates is not installed + } + return undefined; +} + +interface StateTransition { + field: keyof UpdatesNativeStateMachineContext; + message: string; + level: SeverityLevel; + getData?: (ctx: UpdatesNativeStateMachineContext) => Record | undefined; +} + +const STATE_TRANSITIONS: StateTransition[] = [ + { field: 'isChecking', message: 'Checking for update', level: 'info' }, + { + field: 'isUpdateAvailable', + message: 'Update available', + level: 'info', + getData: ctx => { + const updateId = ctx.latestManifest?.id; + return updateId ? { updateId } : undefined; + }, + }, + { field: 'isDownloading', message: 'Downloading update', level: 'info' }, + { + field: 'isUpdatePending', + message: 'Update downloaded', + level: 'info', + getData: ctx => { + const updateId = ctx.downloadedManifest?.id; + return updateId ? { updateId } : undefined; + }, + }, + { + field: 'checkError', + message: 'Update check failed', + level: 'error', + getData: ctx => ({ + error: (ctx.checkError as Error).message || String(ctx.checkError), + }), + }, + { + field: 'downloadError', + message: 'Update download failed', + level: 'error', + getData: ctx => ({ + error: (ctx.downloadError as Error).message || String(ctx.downloadError), + }), + }, + { + field: 'rollback', + message: 'Rollback directive received', + level: 'warning', + getData: ctx => ({ + commitTime: ctx.rollback!.commitTime, + }), + }, + { field: 'isRestarting', message: 'Restarting for update', level: 'info' }, +]; + +/** + * Listens to Expo Updates native state machine changes and records + * breadcrumbs for meaningful transitions such as checking for updates, + * downloading updates, errors, rollbacks, and restarts. + */ +export const expoUpdatesListenerIntegration = (): Integration => { + function setup(client: ReactNativeClient): void { + client.on('afterInit', () => { + if (!isExpo() || isExpoGo()) { + return; + } + + const addListener = getAddUpdatesStateChangeListener(); + if (!addListener) { + debug.log('[ExpoUpdatesListener] expo-updates is not available, skipping.'); + return; + } + + let previousContext: Partial = {}; + + addListener((event: UpdatesNativeStateChangeEvent) => { + const ctx = event.context; + handleStateChange(previousContext, ctx); + previousContext = ctx; + }); + }); + } + + return { + name: INTEGRATION_NAME, + setup, + }; +}; + +/** + * Compares previous and current state machine contexts and emits + * breadcrumbs for meaningful transitions (falsy→truthy). + * + * @internal Exposed for testing purposes + */ +export function handleStateChange( + previous: Partial, + current: UpdatesNativeStateMachineContext, +): void { + for (const transition of STATE_TRANSITIONS) { + if (!previous[transition.field] && current[transition.field]) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: transition.message, + level: transition.level, + data: transition.getData?.(current), + }); + } + } +} diff --git a/packages/core/test/integrations/expoupdateslistener.test.ts b/packages/core/test/integrations/expoupdateslistener.test.ts new file mode 100644 index 0000000000..c473ac07ea --- /dev/null +++ b/packages/core/test/integrations/expoupdateslistener.test.ts @@ -0,0 +1,283 @@ +import { addBreadcrumb, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { expoUpdatesListenerIntegration, handleStateChange } from '../../src/js/integrations/expoupdateslistener'; +import * as environment from '../../src/js/utils/environment'; +import { setupTestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + addBreadcrumb: jest.fn(), + }; +}); + +const mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction; + +describe('ExpoUpdatesListener Integration', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + describe('setup', () => { + it('subscribes to state changes when expo-updates is available', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const mockRemove = jest.fn(); + const mockAddListener = jest.fn().mockReturnValue({ remove: mockRemove }); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockAddListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('does not subscribe when not expo', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const mockAddListener = jest.fn(); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).not.toHaveBeenCalled(); + }); + + it('does not subscribe when in Expo Go', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); + + const mockAddListener = jest.fn(); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).not.toHaveBeenCalled(); + }); + }); + + describe('handleStateChange', () => { + const baseContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, + }; + + beforeEach(() => { + mockAddBreadcrumb.mockClear(); + }); + + it('adds breadcrumb when checking starts', () => { + handleStateChange({ ...baseContext }, { ...baseContext, isChecking: true }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Checking for update', + level: 'info', + }); + }); + + it('does not add breadcrumb when checking stays true', () => { + handleStateChange({ ...baseContext, isChecking: true }, { ...baseContext, isChecking: true }); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('adds breadcrumb when update becomes available', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdateAvailable: true, + latestManifest: { id: 'abc-123' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update available', + level: 'info', + data: { updateId: 'abc-123' }, + }); + }); + + it('adds breadcrumb when update available without manifest id', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdateAvailable: true, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update available', + level: 'info', + data: undefined, + }); + }); + + it('adds breadcrumb when downloading starts', () => { + handleStateChange({ ...baseContext }, { ...baseContext, isDownloading: true }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Downloading update', + level: 'info', + }); + }); + + it('adds breadcrumb when update is downloaded and pending', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdatePending: true, + downloadedManifest: { id: 'def-456' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update downloaded', + level: 'info', + data: { updateId: 'def-456' }, + }); + }); + + it('adds breadcrumb when check error occurs', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + checkError: new Error('Network request failed'), + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update check failed', + level: 'error', + data: { error: 'Network request failed' }, + }); + }); + + it('adds breadcrumb when download error occurs', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + downloadError: new Error('Insufficient storage'), + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update download failed', + level: 'error', + data: { error: 'Insufficient storage' }, + }); + }); + + it('adds breadcrumb when rollback is received', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + rollback: { commitTime: '2025-03-01T00:00:00.000Z' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Rollback directive received', + level: 'warning', + data: { commitTime: '2025-03-01T00:00:00.000Z' }, + }); + }); + + it('adds breadcrumb when restarting starts', () => { + handleStateChange({ ...baseContext }, { ...baseContext, isRestarting: true }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Restarting for update', + level: 'info', + }); + }); + + it('adds multiple breadcrumbs for multiple transitions', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isChecking: true, + isDownloading: true, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(2); + expect(mockAddBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'Checking for update' })); + expect(mockAddBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'Downloading update' })); + }); + + it('does not add breadcrumbs when nothing changes', () => { + handleStateChange({ ...baseContext }, { ...baseContext }); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not re-emit breadcrumbs for already-present errors', () => { + const existingError = new Error('Old error'); + handleStateChange({ ...baseContext, checkError: existingError }, { ...baseContext, checkError: existingError }); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('uses String fallback when error has no message', () => { + const errorWithoutMessage = { toString: () => 'Custom error string' } as unknown as Error; + handleStateChange( + { ...baseContext }, + { + ...baseContext, + checkError: errorWithoutMessage, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: { error: 'Custom error string' }, + }), + ); + }); + }); +});