Skip to content

Commit 6d9272d

Browse files
authored
Add expoUpdatesListenerIntegration that records breadcrumbs for Expo Updates lifecycle events (#5795)
* Expo Updates listener * Lint fixes * Changelog fix * Changelog update * Fix for removing listener * Moving changelog entry * Fixes
1 parent 97afe8e commit 6d9272d

6 files changed

Lines changed: 582 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
### Features
1212

1313
- Support `SENTRY_ENVIRONMENT` in bare React Native builds ([#5823](https://github.com/getsentry/sentry-react-native/pull/5823))
14-
14+
- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events ([#5795](https://github.com/getsentry/sentry-react-native/pull/5795))
15+
- Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs
16+
- Enabled by default in Expo apps (requires `expo-updates` to be installed)
17+
-
1518
### Fixes
1619

1720
- Fix native frames measurements being dropped due to race condition ([#5813](https://github.com/getsentry/sentry-react-native/pull/5813))
0 Bytes
Binary file not shown.

packages/core/src/js/integrations/default.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
eventOriginIntegration,
2020
expoConstantsIntegration,
2121
expoContextIntegration,
22+
expoUpdatesListenerIntegration,
2223
functionToStringIntegration,
2324
hermesProfilingIntegration,
2425
httpClientIntegration,
@@ -133,6 +134,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
133134

134135
integrations.push(expoContextIntegration());
135136
integrations.push(expoConstantsIntegration());
137+
integrations.push(expoUpdatesListenerIntegration());
136138

137139
if (options.spotlight && __DEV__) {
138140
const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined;

packages/core/src/js/integrations/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot';
1212
export { viewHierarchyIntegration } from './viewhierarchy';
1313
export { expoContextIntegration } from './expocontext';
1414
export { expoConstantsIntegration } from './expoconstants';
15+
export { expoUpdatesListenerIntegration } from './expoupdateslistener';
1516
export { spotlightIntegration } from './spotlight';
1617
export { mobileReplayIntegration } from '../replay/mobilereplay';
1718
export { feedbackIntegration } from '../feedback/integration';
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)