Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
eventOriginIntegration,
expoConstantsIntegration,
expoContextIntegration,
expoUpdatesListenerIntegration,
functionToStringIntegration,
hermesProfilingIntegration,
httpClientIntegration,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
183 changes: 183 additions & 0 deletions packages/core/src/js/integrations/expoupdateslistener.ts
Original file line number Diff line number Diff line change
@@ -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<UpdatesNativeStateMachineContext> = {};

_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<UpdatesNativeStateMachineContext>,
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',
});
}
}
Loading
Loading