diff --git a/CHANGELOG.md b/CHANGELOG.md index 046a2c08a9..c489685247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ 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 () + - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs + - Enabled by default in Expo apps (requires `expo-updates` to be installed) ## 8.3.0 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..39d7a453c3 --- /dev/null +++ b/packages/core/src/js/integrations/expoupdateslistener.ts @@ -0,0 +1,183 @@ +import { addBreadcrumb, debug, type Integration } 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; +} + +interface UpdatesStateChangeSubscription { + remove(): void; +} + +/** + * Tries to load `expo-updates` and retrieve `addUpdatesStateChangeListener`. + * Returns `undefined` if `expo-updates` is not installed. + */ +function getAddUpdatesStateChangeListener(): (( + listener: (event: UpdatesNativeStateChangeEvent) => void, +) => UpdatesStateChangeSubscription) | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const expoUpdates = require('expo-updates'); + if (typeof expoUpdates.addUpdatesStateChangeListener === 'function') { + return expoUpdates.addUpdatesStateChangeListener; + } + } catch (_) { + // expo-updates is not installed + } + return undefined; +} + +/** + * 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 => { + let _subscription: UpdatesStateChangeSubscription | undefined; + + 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 = {}; + + _subscription = 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. + * + * @internal Exposed for testing purposes + */ +export function handleStateChange( + previous: Partial, + current: UpdatesNativeStateMachineContext, +): void { + // Checking for update + if (!previous.isChecking && current.isChecking) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Checking for update', + level: 'info', + }); + } + + // Update available + if (!previous.isUpdateAvailable && current.isUpdateAvailable) { + const updateId = current.latestManifest?.id; + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update available', + level: 'info', + data: updateId ? { updateId } : undefined, + }); + } + + // Downloading update + if (!previous.isDownloading && current.isDownloading) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Downloading update', + level: 'info', + }); + } + + // Update downloaded and pending + if (!previous.isUpdatePending && current.isUpdatePending) { + const updateId = current.downloadedManifest?.id; + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update downloaded', + level: 'info', + data: updateId ? { updateId } : undefined, + }); + } + + // Check error + if (!previous.checkError && current.checkError) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update check failed', + level: 'error', + data: { + error: current.checkError.message || String(current.checkError), + }, + }); + } + + // Download error + if (!previous.downloadError && current.downloadError) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update download failed', + level: 'error', + data: { + error: current.downloadError.message || String(current.downloadError), + }, + }); + } + + // Rollback + if (!previous.rollback && current.rollback) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Rollback directive received', + level: 'warning', + data: { + commitTime: current.rollback.commitTime, + }, + }); + } + + // Restarting + if (!previous.isRestarting && current.isRestarting) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Restarting for update', + level: 'info', + }); + } +} diff --git a/packages/core/test/integrations/expoupdateslistener.test.ts b/packages/core/test/integrations/expoupdateslistener.test.ts new file mode 100644 index 0000000000..b867095942 --- /dev/null +++ b/packages/core/test/integrations/expoupdateslistener.test.ts @@ -0,0 +1,296 @@ +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' }, + }), + ); + }); + }); +});