diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e2fb2f7b..7e4dcca3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,10 @@ ### Features - Support `SENTRY_ENVIRONMENT` in bare React Native builds ([#5823](https://github.com/getsentry/sentry-react-native/pull/5823)) - +- 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) + - ### Fixes - Fix native frames measurements being dropped due to race condition ([#5813](https://github.com/getsentry/sentry-react-native/pull/5813)) diff --git a/packages/core/android/libs/replay-stubs.jar b/packages/core/android/libs/replay-stubs.jar index 0f15f49aa4..bb55d330aa 100644 Binary files a/packages/core/android/libs/replay-stubs.jar and b/packages/core/android/libs/replay-stubs.jar differ 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..33a097c86e --- /dev/null +++ b/packages/core/src/js/integrations/expoupdateslistener.ts @@ -0,0 +1,180 @@ +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; +} + +interface UpdatesStateChangeSubscription { + remove(): void; +} + +interface ExpoUpdatesExports { + addUpdatesStateChangeListener: ( + listener: (event: UpdatesNativeStateChangeEvent) => void, + ) => UpdatesStateChangeSubscription; + latestContext: UpdatesNativeStateMachineContext; +} + +/** + * Tries to load `expo-updates` and retrieve exports needed by this integration. + * Returns `undefined` if `expo-updates` is not installed. + */ +function getExpoUpdatesExports(): ExpoUpdatesExports | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const expoUpdates = require('expo-updates') as Partial; + if (typeof expoUpdates.addUpdatesStateChangeListener === 'function') { + return expoUpdates as ExpoUpdatesExports; + } + } 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 => { + let subscription: UpdatesStateChangeSubscription | undefined; + + function setup(client: ReactNativeClient): void { + client.on('afterInit', () => { + if (!isExpo() || isExpoGo()) { + return; + } + + const expoUpdates = getExpoUpdatesExports(); + if (!expoUpdates) { + debug.log('[ExpoUpdatesListener] expo-updates is not available, skipping.'); + return; + } + + // Remove any previous subscription to prevent duplicate breadcrumbs + // if Sentry.init() is called multiple times. + subscription?.remove(); + + // Seed with the current state so that the first event does not + // generate spurious breadcrumbs for already-truthy fields. + let previousContext: Partial = expoUpdates.latestContext ?? {}; + + subscription = expoUpdates.addUpdatesStateChangeListener((event: UpdatesNativeStateChangeEvent) => { + const ctx = event.context; + handleStateChange(previousContext, ctx); + previousContext = ctx; + }); + }); + + client.on('close', () => { + subscription?.remove(); + subscription = undefined; + }); + } + + 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..05f9233fb2 --- /dev/null +++ b/packages/core/test/integrations/expoupdateslistener.test.ts @@ -0,0 +1,395 @@ +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 mockRemove = jest.fn(); +const mockAddListener = jest.fn().mockReturnValue({ remove: mockRemove }); +const mockLatestContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, +}; +const mockExpoUpdates = { + addUpdatesStateChangeListener: mockAddListener, + latestContext: mockLatestContext, +}; +jest.mock('expo-updates', () => mockExpoUpdates, { virtual: true }); + +const mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction; + +describe('ExpoUpdatesListener Integration', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // Reset latestContext to default idle state + mockExpoUpdates.latestContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, + }; + + 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); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockAddListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('removes subscription on client close', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const client = setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockRemove).not.toHaveBeenCalled(); + + // @ts-expect-error emit is not typed for 'close' on TestClient + client.emit('close'); + + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + + it('removes previous subscription when setup is called again', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const mockRemove1 = jest.fn(); + const mockRemove2 = jest.fn(); + mockAddListener.mockReturnValueOnce({ remove: mockRemove1 }).mockReturnValueOnce({ remove: mockRemove2 }); + + const integration = expoUpdatesListenerIntegration(); + setupTestClient({ enableNative: true, integrations: [integration] }); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockRemove1).not.toHaveBeenCalled(); + + // Simulate a second Sentry.init() reusing the same integration instance + const client2 = setupTestClient({ enableNative: true, integrations: [integration] }); + + expect(mockAddListener).toHaveBeenCalledTimes(2); + expect(mockRemove1).toHaveBeenCalledTimes(1); + expect(mockRemove2).not.toHaveBeenCalled(); + + // @ts-expect-error emit is not typed for 'close' on TestClient + client2.emit('close'); + + expect(mockRemove2).toHaveBeenCalledTimes(1); + }); + + it('seeds previousContext from latestContext to avoid spurious breadcrumbs', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + // Simulate an update already pending before Sentry.init() + mockExpoUpdates.latestContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: true, + isUpdatePending: true, + isRestarting: false, + latestManifest: { id: 'pre-existing-123' }, + downloadedManifest: { id: 'pre-existing-123' }, + }; + + let capturedListener: ((event: { context: Record }) => void) | undefined; + mockAddListener.mockImplementation((listener: (event: { context: Record }) => void) => { + capturedListener = listener; + return { remove: jest.fn() }; + }); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + // First event repeats the same state — should NOT produce breadcrumbs + capturedListener!({ + context: { + isChecking: false, + isDownloading: false, + isUpdateAvailable: true, + isUpdatePending: true, + isRestarting: false, + latestManifest: { id: 'pre-existing-123' }, + downloadedManifest: { id: 'pre-existing-123' }, + }, + }); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('emits breadcrumb for new transitions after seeded state', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + // Simulate an idle state before Sentry.init() + mockExpoUpdates.latestContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, + }; + + let capturedListener: ((event: { context: Record }) => void) | undefined; + mockAddListener.mockImplementation((listener: (event: { context: Record }) => void) => { + capturedListener = listener; + return { remove: jest.fn() }; + }); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + // A genuine new transition should still produce a breadcrumb + capturedListener!({ + context: { + isChecking: true, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, + }, + }); + + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'Checking for update' })); + }); + + it('does not subscribe when not expo', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + 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); + + 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' }, + }), + ); + }); + }); +});