|
| 1 | +import { addBreadcrumb, debug, type Integration, type SeverityLevel } from '@sentry/core'; |
| 2 | +import type { ReactNativeClient } from '../client'; |
| 3 | +import { isExpo, isExpoGo } from '../utils/environment'; |
| 4 | + |
| 5 | +const INTEGRATION_NAME = 'ExpoUpdatesListener'; |
| 6 | + |
| 7 | +const BREADCRUMB_CATEGORY = 'expo.updates'; |
| 8 | + |
| 9 | +/** |
| 10 | + * Describes the state machine context from `expo-updates`. |
| 11 | + * We define our own minimal type to avoid a hard dependency on `expo-updates`. |
| 12 | + */ |
| 13 | +interface UpdatesNativeStateMachineContext { |
| 14 | + isChecking: boolean; |
| 15 | + isDownloading: boolean; |
| 16 | + isUpdateAvailable: boolean; |
| 17 | + isUpdatePending: boolean; |
| 18 | + isRestarting: boolean; |
| 19 | + latestManifest?: { id?: string }; |
| 20 | + downloadedManifest?: { id?: string }; |
| 21 | + rollback?: { commitTime: string }; |
| 22 | + checkError?: Error; |
| 23 | + downloadError?: Error; |
| 24 | +} |
| 25 | + |
| 26 | +interface UpdatesNativeStateChangeEvent { |
| 27 | + context: UpdatesNativeStateMachineContext; |
| 28 | +} |
| 29 | + |
| 30 | +interface UpdatesStateChangeSubscription { |
| 31 | + remove(): void; |
| 32 | +} |
| 33 | + |
| 34 | +interface ExpoUpdatesExports { |
| 35 | + addUpdatesStateChangeListener: ( |
| 36 | + listener: (event: UpdatesNativeStateChangeEvent) => void, |
| 37 | + ) => UpdatesStateChangeSubscription; |
| 38 | + latestContext: UpdatesNativeStateMachineContext; |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Tries to load `expo-updates` and retrieve exports needed by this integration. |
| 43 | + * Returns `undefined` if `expo-updates` is not installed. |
| 44 | + */ |
| 45 | +function getExpoUpdatesExports(): ExpoUpdatesExports | undefined { |
| 46 | + try { |
| 47 | + // eslint-disable-next-line @typescript-eslint/no-var-requires |
| 48 | + const expoUpdates = require('expo-updates') as Partial<ExpoUpdatesExports>; |
| 49 | + if (typeof expoUpdates.addUpdatesStateChangeListener === 'function') { |
| 50 | + return expoUpdates as ExpoUpdatesExports; |
| 51 | + } |
| 52 | + } catch (_) { |
| 53 | + // that happens when expo-updates is not installed |
| 54 | + } |
| 55 | + return undefined; |
| 56 | +} |
| 57 | + |
| 58 | +interface StateTransition { |
| 59 | + field: keyof UpdatesNativeStateMachineContext; |
| 60 | + message: string; |
| 61 | + level: SeverityLevel; |
| 62 | + getData?: (ctx: UpdatesNativeStateMachineContext) => Record<string, unknown> | undefined; |
| 63 | +} |
| 64 | + |
| 65 | +const STATE_TRANSITIONS: StateTransition[] = [ |
| 66 | + { field: 'isChecking', message: 'Checking for update', level: 'info' }, |
| 67 | + { |
| 68 | + field: 'isUpdateAvailable', |
| 69 | + message: 'Update available', |
| 70 | + level: 'info', |
| 71 | + getData: ctx => { |
| 72 | + const updateId = ctx.latestManifest?.id; |
| 73 | + return updateId ? { updateId } : undefined; |
| 74 | + }, |
| 75 | + }, |
| 76 | + { field: 'isDownloading', message: 'Downloading update', level: 'info' }, |
| 77 | + { |
| 78 | + field: 'isUpdatePending', |
| 79 | + message: 'Update downloaded', |
| 80 | + level: 'info', |
| 81 | + getData: ctx => { |
| 82 | + const updateId = ctx.downloadedManifest?.id; |
| 83 | + return updateId ? { updateId } : undefined; |
| 84 | + }, |
| 85 | + }, |
| 86 | + { |
| 87 | + field: 'checkError', |
| 88 | + message: 'Update check failed', |
| 89 | + level: 'error', |
| 90 | + getData: ctx => ({ |
| 91 | + error: (ctx.checkError as Error).message || String(ctx.checkError), |
| 92 | + }), |
| 93 | + }, |
| 94 | + { |
| 95 | + field: 'downloadError', |
| 96 | + message: 'Update download failed', |
| 97 | + level: 'error', |
| 98 | + getData: ctx => ({ |
| 99 | + error: (ctx.downloadError as Error).message || String(ctx.downloadError), |
| 100 | + }), |
| 101 | + }, |
| 102 | + { |
| 103 | + field: 'rollback', |
| 104 | + message: 'Rollback directive received', |
| 105 | + level: 'warning', |
| 106 | + getData: ctx => ({ |
| 107 | + commitTime: ctx.rollback!.commitTime, |
| 108 | + }), |
| 109 | + }, |
| 110 | + { field: 'isRestarting', message: 'Restarting for update', level: 'info' }, |
| 111 | +]; |
| 112 | + |
| 113 | +/** |
| 114 | + * Listens to Expo Updates native state machine changes and records |
| 115 | + * breadcrumbs for meaningful transitions such as checking for updates, |
| 116 | + * downloading updates, errors, rollbacks, and restarts. |
| 117 | + */ |
| 118 | +export const expoUpdatesListenerIntegration = (): Integration => { |
| 119 | + let subscription: UpdatesStateChangeSubscription | undefined; |
| 120 | + |
| 121 | + function setup(client: ReactNativeClient): void { |
| 122 | + client.on('afterInit', () => { |
| 123 | + if (!isExpo() || isExpoGo()) { |
| 124 | + return; |
| 125 | + } |
| 126 | + |
| 127 | + const expoUpdates = getExpoUpdatesExports(); |
| 128 | + if (!expoUpdates) { |
| 129 | + debug.log('[ExpoUpdatesListener] expo-updates is not available, skipping.'); |
| 130 | + return; |
| 131 | + } |
| 132 | + |
| 133 | + // Remove any previous subscription to prevent duplicate breadcrumbs |
| 134 | + // if Sentry.init() is called multiple times. |
| 135 | + subscription?.remove(); |
| 136 | + |
| 137 | + // Seed with the current state so that the first event does not |
| 138 | + // generate spurious breadcrumbs for already-truthy fields. |
| 139 | + let previousContext: Partial<UpdatesNativeStateMachineContext> = expoUpdates.latestContext ?? {}; |
| 140 | + |
| 141 | + subscription = expoUpdates.addUpdatesStateChangeListener((event: UpdatesNativeStateChangeEvent) => { |
| 142 | + const ctx = event.context; |
| 143 | + handleStateChange(previousContext, ctx); |
| 144 | + previousContext = ctx; |
| 145 | + }); |
| 146 | + }); |
| 147 | + |
| 148 | + client.on('close', () => { |
| 149 | + subscription?.remove(); |
| 150 | + subscription = undefined; |
| 151 | + }); |
| 152 | + } |
| 153 | + |
| 154 | + return { |
| 155 | + name: INTEGRATION_NAME, |
| 156 | + setup, |
| 157 | + }; |
| 158 | +}; |
| 159 | + |
| 160 | +/** |
| 161 | + * Compares previous and current state machine contexts and emits |
| 162 | + * breadcrumbs for meaningful transitions (falsy→truthy). |
| 163 | + * |
| 164 | + * @internal Exposed for testing purposes |
| 165 | + */ |
| 166 | +export function handleStateChange( |
| 167 | + previous: Partial<UpdatesNativeStateMachineContext>, |
| 168 | + current: UpdatesNativeStateMachineContext, |
| 169 | +): void { |
| 170 | + for (const transition of STATE_TRANSITIONS) { |
| 171 | + if (!previous[transition.field] && current[transition.field]) { |
| 172 | + addBreadcrumb({ |
| 173 | + category: BREADCRUMB_CATEGORY, |
| 174 | + message: transition.message, |
| 175 | + level: transition.level, |
| 176 | + data: transition.getData?.(current), |
| 177 | + }); |
| 178 | + } |
| 179 | + } |
| 180 | +} |
0 commit comments