Skip to content

Commit a200b56

Browse files
feat: add/improve Sentry instrumentation for storage related error handling (#39113)
## Description This PR is a follow-up PR for #39010 It adds Sentry instrumentation to monitor storage-related errors and enables error reporting even during database corruption scenarios. ### Context Firefox users occasionally experience database corruption that prevents MetaMask from reading or writing to `storage.local`. This manifests as `"Error: An unexpected error occurred"`. We have workarounds in place (vault recovery flow, storage error toast), but we lacked visibility into how often these occur in production. ### Changes ### 1. Added Sentry Tags and Fingerprints - Added `persistence.error` tags to `captureException` calls: - `get-failed` - when `storage.local.get()` fails - `set-failed` - when `storage.local.set()` fails - `persist-failed` - when IndexedDB backup fails - `backup-db-open-failed` - when backup database can't be opened - Added custom fingerprints to prevent Sentry's deduplication from dropping events with the same underlying error message (e.g., Firefox's generic "An unexpected error occurred") ### 2. Enabled Sentry Reporting During Storage Corruption - Added `MetaMetricsController` to the list of backed-up controllers in IndexedDB - Added `getBackupState` hook to allow Sentry to check MetaMetrics consent from backup when primary storage is unavailable - This ensures we can report errors to Sentry even during database corruption scenarios (when primary storage is unreadable) ### 3. Added Recovery Tracking for Temporary Failures - Added `captureMessage` calls to track when storage operations recover after a temporary failure: - `set-recovered` - when `storage.local.set()` succeeds after a previous failure - `persist-recovered` - when IndexedDB backup succeeds after a previous failure - This helps answer the question: "Do set calls ever fail and then succeed in the same session?" - Uses `persistence.event` tag (vs `persistence.error`) to distinguish recovery events from error events ### 4. Fixed State Persistence in Backup Code Path - Fixed bug where backup code wrote unfiltered controller state (including non-persistent properties like `KeyringController.encryptionKey`) to storage - Now uses `deriveStateFromMetadata()` to filter to only `persist: true` properties, matching the normal `stateChange` behavior ## **Changelog** CHANGELOG entry: Adds Sentry instrumentation to monitor storage-related errors and enables error reporting even during database corruption scenarios. ## **Related issues** None ## **Manual testing steps** Same as in this PR: #39010 ## **Screenshots/Recordings** ### **Before** Nothing visible in Sentry ### **After** <img width="1426" height="328" alt="Screenshot 2026-01-08 at 22 24 27" src="https://github.com/user-attachments/assets/eee8d9fd-2fb6-40f3-9268-5c267a4028dd" /> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Sentry instrumentation & recovery** > > - Add `persistence.error` tags and custom fingerprints to `captureException` for storage paths: `get-failed`, `set-failed`, `set-backup-failed`, `persist-failed`, `persist-backup-failed`, and `backup-db-open-failed`. > - Emit `captureMessage` recovery signals with `persistence.event` tags: `set-recovered` and `persist-recovered` when writes/backups succeed after a prior failure. > - Log & Sentry-capture `storage.local.get` errors without immediately throwing; throw `PersistenceError` with backup payload when vault is missing and a backup exists. > > **Backup & consent fallback** > > - Add `MetaMetricsController` to `backedUpStateKeys` and `Backup`; update `getBackup()` accordingly. > - In `background.js` split-state `stateChange`, use `deriveStateFromMetadata(..., 'persist')` to only back up persistent controller fields; throw if metadata missing. > - New `stateHooks.getBackupState()` to read IndexedDB backup; `setupSentry.getMetaMetricsEnabled` falls back to backup state when primary storage retrieval fails; add `getMetaMetricsEnabledFromBackupState`. > > **PersistenceManager robustness** > > - Differentiate storage.local vs IndexedDB backup failures; add Sentry tags/fingerprints per failure type and recovery notifications; notify UI via `setOnSetFailed` when writes fail. > - Handle Firefox private-browsing IndexedDB `InvalidStateError` with tagged Sentry capture and graceful degradation. > > **Tests** > > - Update `persistence-manager.test.ts` to verify new tags/fingerprints, recovery `captureMessage` events, backup DB open error handling, and repeated failure/success behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1ca4d4d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 3cc11bc commit a200b56

File tree

4 files changed

+177
-19
lines changed

4 files changed

+177
-19
lines changed

app/scripts/lib/setup-initial-state-hooks.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ globalThis.stateHooks.getPersistedState = async function () {
2727
return await sentryLocalStore.get({ validateVault: false });
2828
};
2929

30+
/**
31+
* Get the backup state from IndexedDB.
32+
* This is used as a fallback when primary storage is unavailable.
33+
*
34+
* @returns The backup state, or null if unavailable.
35+
*/
36+
globalThis.stateHooks.getBackupState = async function () {
37+
return await sentryLocalStore.getBackup();
38+
};
39+
3040
const persistedStateMask = {
3141
data: SENTRY_BACKGROUND_STATE,
3242
meta: {

app/scripts/lib/setupSentry.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ function getMetaMetricsEnabledFromPersistedState(persistedState) {
205205
);
206206
}
207207

208+
/**
209+
* Returns whether MetaMetrics is enabled, given the backup state.
210+
*
211+
* @param {unknown} backupState - Backup state from IndexedDB
212+
* @returns `true` if MetaMetrics is enabled in the backup, `false` otherwise.
213+
*/
214+
function getMetaMetricsEnabledFromBackupState(backupState) {
215+
return Boolean(backupState?.MetaMetricsController?.participateInMetaMetrics);
216+
}
217+
208218
function getSentryEnvironment() {
209219
if (METAMASK_BUILD_TYPE === 'main') {
210220
return METAMASK_ENVIRONMENT;
@@ -265,8 +275,15 @@ async function getMetaMetricsEnabled() {
265275
const persistedState = await globalThis.stateHooks.getPersistedState();
266276
return getMetaMetricsEnabledFromPersistedState(persistedState);
267277
} catch (error) {
268-
log('Error retrieving persisted state', error);
269-
return false;
278+
log('Error retrieving persisted state, falling back to backup', error);
279+
// Primary storage failed (e.g., database corruption) - check the backup
280+
try {
281+
const backupState = await globalThis.stateHooks.getBackupState();
282+
return getMetaMetricsEnabledFromBackupState(backupState);
283+
} catch (backupError) {
284+
log('Error retrieving backup state', backupError);
285+
return false;
286+
}
270287
}
271288
}
272289

app/scripts/lib/stores/persistence-manager.test.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import 'navigator.locks';
44
import log from 'loglevel';
55

6-
import { captureException } from '../../../../shared/lib/sentry';
6+
import {
7+
captureException,
8+
captureMessage,
9+
} from '../../../../shared/lib/sentry';
710
import { MISSING_VAULT_ERROR } from '../../../../shared/constants/errors';
811
import { PersistenceManager } from './persistence-manager';
912
import ExtensionStore from './extension-store';
@@ -32,8 +35,10 @@ jest.mock('loglevel', () => ({
3235
}));
3336
jest.mock('../../../../shared/lib/sentry', () => ({
3437
captureException: jest.fn(),
38+
captureMessage: jest.fn(),
3539
}));
3640
const mockedCaptureException = jest.mocked(captureException);
41+
const mockedCaptureMessage = jest.mocked(captureMessage);
3742

3843
describe('PersistenceManager', () => {
3944
let manager: PersistenceManager;
@@ -78,7 +83,10 @@ describe('PersistenceManager', () => {
7883
mockStoreSet.mockRejectedValueOnce(error);
7984

8085
await manager.set({ appState: { broken: true } });
81-
expect(mockedCaptureException).toHaveBeenCalledWith(error);
86+
expect(mockedCaptureException).toHaveBeenCalledWith(error, {
87+
tags: { 'persistence.error': 'set-failed' },
88+
fingerprint: ['persistence-error', 'set-failed'],
89+
});
8290
expect(log.error).toHaveBeenCalledWith(
8391
'error setting state in local store:',
8492
error,
@@ -116,6 +124,43 @@ describe('PersistenceManager', () => {
116124

117125
expect(mockedCaptureException).toHaveBeenCalledTimes(2);
118126
});
127+
128+
it('tracks recovery with captureMessage when store.set fails then succeeds', async () => {
129+
manager.setMetadata({ version: 17 });
130+
131+
const error = new Error('store.set error');
132+
mockStoreSet.mockRejectedValueOnce(error);
133+
134+
// First set fails
135+
await manager.set({ appState: { broken: true } });
136+
137+
expect(mockedCaptureException).toHaveBeenCalledTimes(1);
138+
expect(mockedCaptureMessage).not.toHaveBeenCalled();
139+
140+
// Second set succeeds - should trigger recovery tracking
141+
mockStoreSet.mockResolvedValueOnce(undefined);
142+
await manager.set({ appState: { fixed: true } });
143+
144+
expect(mockedCaptureMessage).toHaveBeenCalledTimes(1);
145+
expect(mockedCaptureMessage).toHaveBeenCalledWith(
146+
'Data persistence recovered after temporary failure',
147+
{
148+
level: 'info',
149+
tags: { 'persistence.event': 'set-recovered' },
150+
fingerprint: ['persistence-event', 'set-recovered'],
151+
},
152+
);
153+
});
154+
155+
it('does not track recovery if set never failed', async () => {
156+
manager.setMetadata({ version: 17 });
157+
158+
// Set succeeds without prior failure
159+
mockStoreSet.mockResolvedValueOnce(undefined);
160+
await manager.set({ appState: { working: true } });
161+
162+
expect(mockedCaptureMessage).not.toHaveBeenCalled();
163+
});
119164
});
120165

121166
describe('get', () => {
@@ -264,7 +309,10 @@ describe('PersistenceManager', () => {
264309

265310
await manager.persist();
266311

267-
expect(mockedCaptureException).toHaveBeenCalledWith(error);
312+
expect(mockedCaptureException).toHaveBeenCalledWith(error, {
313+
tags: { 'persistence.error': 'persist-failed' },
314+
fingerprint: ['persistence-error', 'persist-failed'],
315+
});
268316
expect(log.error).toHaveBeenCalledWith(
269317
'error setting state in local store:',
270318
error,
@@ -423,7 +471,10 @@ describe('PersistenceManager', () => {
423471
// returns `undefined`
424472
expect(await brokenManager.getBackup()).toBeUndefined();
425473

426-
expect(mockedCaptureException).toHaveBeenCalledWith(domException);
474+
expect(mockedCaptureException).toHaveBeenCalledWith(domException, {
475+
tags: { 'persistence.error': 'backup-db-open-failed' },
476+
fingerprint: ['persistence-error', 'backup-db-open-failed'],
477+
});
427478
expect(consoleWarnSpy).toHaveBeenCalledWith(
428479
'Could not open backup database; automatic vault recovery will not be available.',
429480
);

app/scripts/lib/stores/persistence-manager.ts

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import log from 'loglevel';
22
import { isEmpty } from 'lodash';
33
import { RuntimeObject, hasProperty, isObject } from '@metamask/utils';
4-
import { captureException } from '../../../../shared/lib/sentry';
4+
import {
5+
captureException,
6+
captureMessage,
7+
} from '../../../../shared/lib/sentry';
58
import { MISSING_VAULT_ERROR } from '../../../../shared/constants/errors';
69
import { getManifestFlags } from '../../../../shared/lib/manifestFlags';
710
import { IndexedDBStore } from './indexeddb-store';
@@ -22,12 +25,16 @@ export type Backup = {
2225
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
2326
// eslint-disable-next-line @typescript-eslint/naming-convention
2427
AppMetadataController?: unknown;
28+
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
29+
// eslint-disable-next-line @typescript-eslint/naming-convention
30+
MetaMetricsController?: unknown;
2531
meta?: MetaData;
2632
};
2733

2834
export const backedUpStateKeys = [
2935
'KeyringController',
3036
'AppMetadataController',
37+
'MetaMetricsController',
3138
] as const;
3239

3340
export type BackedUpStateKey = (typeof backedUpStateKeys)[number];
@@ -237,7 +244,13 @@ export class PersistenceManager {
237244
error.message ===
238245
'A mutation operation was attempted on a database that did not allow mutations.'
239246
) {
240-
captureException(error);
247+
// Custom fingerprint prevents Sentry's deduplication from dropping
248+
// this event when other persistence errors with the same underlying
249+
// error message (e.g., "An unexpected error occurred") are reported.
250+
captureException(error, {
251+
tags: { 'persistence.error': 'backup-db-open-failed' },
252+
fingerprint: ['persistence-error', 'backup-db-open-failed'],
253+
});
241254
console.warn(
242255
'Could not open backup database; automatic vault recovery will not be available.',
243256
);
@@ -326,6 +339,8 @@ export class PersistenceManager {
326339
{ mode: 'exclusive', signal: abortController.signal },
327340
async () => {
328341
this.#currentLockAbortController = undefined;
342+
// Track which operation failed to use the correct Sentry tag
343+
let backupFailed = false;
329344
try {
330345
// atomically set all the keys
331346
await this.#localStore.set({
@@ -339,19 +354,44 @@ export class PersistenceManager {
339354
const stringifiedBackup = JSON.stringify(backup);
340355
// and the backup has changed
341356
if (this.#backup !== stringifiedBackup) {
342-
// save it to the backup DB
343-
await this.#backupDb?.set(backup);
344-
this.#backup = stringifiedBackup;
357+
// save it to the backup DB - wrapped in try-catch to differentiate
358+
// backup failures from storage.local failures in Sentry
359+
try {
360+
await this.#backupDb?.set(backup);
361+
this.#backup = stringifiedBackup;
362+
} catch (backupErr) {
363+
backupFailed = true;
364+
throw backupErr;
365+
}
345366
}
346367
}
347368

348369
if (this.#dataPersistenceFailing) {
349370
this.#dataPersistenceFailing = false;
371+
// Track recovery to understand how often failures are temporary.
372+
// This helps answer: "Do set calls ever fail and then succeed in the same session?"
373+
captureMessage(
374+
'Data persistence recovered after temporary failure',
375+
{
376+
level: 'info',
377+
tags: { 'persistence.event': 'set-recovered' },
378+
fingerprint: ['persistence-event', 'set-recovered'],
379+
},
380+
);
350381
}
351382
} catch (err) {
352383
if (!this.#dataPersistenceFailing) {
353384
this.#dataPersistenceFailing = true;
354-
captureException(err);
385+
// Use different tags to differentiate storage.local vs IndexedDB backup failures.
386+
const tag = backupFailed ? 'set-backup-failed' : 'set-failed';
387+
388+
// Custom fingerprint prevents Sentry's deduplication from dropping
389+
// this event when other persistence errors with the same underlying
390+
// error message (e.g., "An unexpected error occurred") are reported.
391+
captureException(err, {
392+
tags: { 'persistence.error': tag },
393+
fingerprint: ['persistence-error', tag],
394+
});
355395
}
356396
this.#notifySetFailed();
357397
log.error('error setting state in local store:', err);
@@ -413,6 +453,8 @@ export class PersistenceManager {
413453
{ mode: 'exclusive', signal: abortController.signal },
414454
async () => {
415455
this.#currentLockAbortController = undefined;
456+
// Track which operation failed to use the correct Sentry tag
457+
let backupFailed = false;
416458
try {
417459
const clone = structuredClone(this.#pendingPairs);
418460
// reset the pendingPairs
@@ -441,17 +483,44 @@ export class PersistenceManager {
441483
}
442484
if (hasVault(partialState)) {
443485
const backup = makeBackup(partialState, meta);
444-
// save it to the backup DB
445-
await this.#backupDb?.set(backup);
486+
// save it to the backup DB - wrapped in try-catch to differentiate
487+
// backup failures from storage.local failures in Sentry
488+
try {
489+
await this.#backupDb?.set(backup);
490+
} catch (backupErr) {
491+
backupFailed = true;
492+
throw backupErr;
493+
}
446494
}
447495

448496
if (this.#dataPersistenceFailing) {
449497
this.#dataPersistenceFailing = false;
498+
// Track recovery to understand how often failures are temporary.
499+
// This helps answer: "Do set calls ever fail and then succeed in the same session?"
500+
captureMessage(
501+
'Data persistence recovered after temporary failure',
502+
{
503+
level: 'info',
504+
tags: { 'persistence.event': 'persist-recovered' },
505+
fingerprint: ['persistence-event', 'persist-recovered'],
506+
},
507+
);
450508
}
451509
} catch (err) {
452510
if (!this.#dataPersistenceFailing) {
453511
this.#dataPersistenceFailing = true;
454-
captureException(err);
512+
// Use different tags to differentiate storage.local vs IndexedDB backup failures.
513+
const tag = backupFailed
514+
? 'persist-backup-failed'
515+
: 'persist-failed';
516+
517+
// Custom fingerprint prevents Sentry's deduplication from dropping
518+
// this event when other persistence errors with the same underlying
519+
// error message (e.g., "An unexpected error occurred") are reported.
520+
captureException(err, {
521+
tags: { 'persistence.error': tag },
522+
fingerprint: ['persistence-error', tag],
523+
});
455524
}
456525
this.#notifySetFailed();
457526
log.error('error setting state in local store:', err);
@@ -493,12 +562,19 @@ export class PersistenceManager {
493562
])
494563
.catch((error: Error): [Error, undefined] => [error, undefined]);
495564

496-
// Log the error if one occurred, but don't throw yet
565+
// Log and capture the error if one occurred, but don't throw yet
497566
if (localStoreError) {
498567
log.error(
499568
'Error retrieving the current state of the local store:',
500569
localStoreError,
501570
);
571+
// Custom fingerprint prevents Sentry's deduplication from dropping
572+
// this event when other persistence errors with the same underlying
573+
// error message (e.g., "An unexpected error occurred") are reported.
574+
captureException(localStoreError, {
575+
tags: { 'persistence.error': 'get-failed' },
576+
fingerprint: ['persistence-error', 'get-failed'],
577+
});
502578
}
503579

504580
if (validateVault) {
@@ -601,12 +677,16 @@ export class PersistenceManager {
601677
if (!backupDb) {
602678
return undefined;
603679
}
604-
const [KeyringController, AppMetadataController, meta] = await backupDb.get(
605-
[...backedUpStateKeys, `meta`],
606-
);
680+
const [
681+
KeyringController,
682+
AppMetadataController,
683+
MetaMetricsController,
684+
meta,
685+
] = await backupDb.get([...backedUpStateKeys, `meta`]);
607686
return {
608687
KeyringController,
609688
AppMetadataController,
689+
MetaMetricsController,
610690
meta: meta as MetaData | undefined,
611691
};
612692
}

0 commit comments

Comments
 (0)