Skip to content

Commit 51b6bbd

Browse files
VGR-GITclaude
andauthored
feat(rewards): add targeted Sentry capture for rewards auth errors (MetaMask#29545)
## Summary Adds `captureException` to six targeted catch blocks across the rewards controller and hooks. Scoped intentionally to avoid noise — only errors that are either user-facing, represent rare high-severity auth failures, or are unexpected outer-catch escapes from flows with visible user impact. ### Instrumented locations | Location | Context tag | When it fires | |---|---|---| | `useCandidateSubscriptionId` catch | `candidateSubscriptionId.fetch_failed` | Every time the auth-failed modal is shown to the user | | `RewardsController.performSilentAuth` non-401 branch | `performSilentAuth.unexpected_error` | Unexpected server errors (e.g. 500s) during silent auth — rare | | `RewardsController.#withAuthRetry` reauthError catch | `withAuthRetry.reauth_failed` | 403 recovery itself throws, forcing cache invalidation — very rare | | `RewardsController.#optIn` outer catch | `optIn.unexpected_error` | Unexpected failures after inner InvalidTimestamp/AlreadyRegistered handling | | `RewardsController.linkAccountToSubscriptionCandidate` catch | `linkAccountToSubscriptionCandidate.failed` | Unexpected failures after inner retry/recovery handling | | `RewardsController.optOut` catch | `optOut.failed` | API 500s or network errors during opt-out | All events tagged `{ feature: 'rewards', context: '<location>' }`. The three captures with an `InternalAccount` in scope also emit `extra: { accountType }` (chain namespace, e.g. `eip155:eoa`) for filtering by account kind without exposing PII. ## Changelog CHANGELOG entry: null 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily adds observability side effects in error paths without changing success-path behavior; low risk aside from potential for minor telemetry noise or additional overhead when failures occur. > > **Overview** > Adds targeted Sentry error reporting to rewards flows by calling `captureException` in select catch blocks across `useCandidateSubscriptionId` and `RewardsController` (silent auth unexpected failures, 403 reauth retry failures, opt-in/linking failures, and opt-out failures), consistently tagged with `{ feature: 'rewards', context: ... }` and including `accountType` as extra where available. > > Updates and extends unit tests to mock Sentry and assert the new `captureException` calls (including new coverage for non-401 silent-auth login errors and other unexpected failure cases). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ccf3146. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e717be3 commit 51b6bbd

4 files changed

Lines changed: 149 additions & 0 deletions

File tree

app/components/UI/Rewards/hooks/useCandidateSubscriptionId.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { renderHook, act } from '@testing-library/react-hooks';
22
import { useDispatch, useSelector } from 'react-redux';
3+
import { captureException } from '@sentry/react-native';
34
import { useCandidateSubscriptionId } from './useCandidateSubscriptionId';
45
import Engine from '../../../../core/Engine';
56
import { setCandidateSubscriptionId } from '../../../../actions/rewards';
@@ -11,6 +12,10 @@ jest.mock('react-redux', () => ({
1112
useSelector: jest.fn(),
1213
}));
1314

15+
jest.mock('@sentry/react-native', () => ({
16+
captureException: jest.fn(),
17+
}));
18+
1419
jest.mock('../../../../core/Engine', () => ({
1520
controllerMessenger: {
1621
call: jest.fn(),
@@ -42,6 +47,9 @@ describe('useCandidateSubscriptionId', () => {
4247
const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction<
4348
typeof Engine.controllerMessenger.call
4449
>;
50+
const mockCaptureException = captureException as jest.MockedFunction<
51+
typeof captureException
52+
>;
4553

4654
beforeEach(() => {
4755
jest.clearAllMocks();
@@ -105,6 +113,12 @@ describe('useCandidateSubscriptionId', () => {
105113
expect(mockEngineCall).toHaveBeenCalledWith(
106114
'RewardsController:getCandidateSubscriptionId',
107115
);
116+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, {
117+
tags: {
118+
feature: 'rewards',
119+
context: 'candidateSubscriptionId.fetch_failed',
120+
},
121+
});
108122
expect(mockDispatch).toHaveBeenCalledWith(
109123
setCandidateSubscriptionId('error'),
110124
);
@@ -157,6 +171,12 @@ describe('useCandidateSubscriptionId', () => {
157171
expect(mockEngineCall).toHaveBeenCalledWith(
158172
'RewardsController:getCandidateSubscriptionId',
159173
);
174+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, {
175+
tags: {
176+
feature: 'rewards',
177+
context: 'candidateSubscriptionId.fetch_failed',
178+
},
179+
});
160180
expect(mockDispatch).toHaveBeenCalledWith(
161181
setCandidateSubscriptionId('error'),
162182
);

app/components/UI/Rewards/hooks/useCandidateSubscriptionId.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect } from 'react';
22
import { useDispatch, useSelector } from 'react-redux';
3+
import { captureException } from '@sentry/react-native';
34
import { setCandidateSubscriptionId } from '../../../../actions/rewards';
45
import { selectCandidateSubscriptionId } from '../../../../reducers/rewards/selectors';
56
import Engine from '../../../../core/Engine';
@@ -20,6 +21,12 @@ export const useCandidateSubscriptionId = () => {
2021
);
2122
dispatch(setCandidateSubscriptionId(candidateId));
2223
} catch (error) {
24+
captureException(error as Error, {
25+
tags: {
26+
feature: 'rewards',
27+
context: 'candidateSubscriptionId.fetch_failed',
28+
},
29+
});
2330
dispatch(setCandidateSubscriptionId('error'));
2431
}
2532
}, [dispatch]);

app/core/Engine/controllers/rewards-controller/RewardsController.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import {
114114
SeasonNotFoundError,
115115
RewardsDataService,
116116
} from './services/rewards-data-service';
117+
import { captureException } from '@sentry/react-native';
117118

118119
// Type the mocked modules
119120
// Note: mockStore is kept for potential future use but currently unused after feature flag removal
@@ -136,6 +137,9 @@ const mockToHex = toHex as jest.MockedFunction<typeof toHex>;
136137
const mockLogger = Logger as jest.Mocked<typeof Logger>;
137138
const mockStoreSubscriptionToken =
138139
storeSubscriptionToken as jest.MockedFunction<typeof storeSubscriptionToken>;
140+
const mockCaptureException = captureException as jest.MockedFunction<
141+
typeof captureException
142+
>;
139143
const mockRemoveSubscriptionToken =
140144
removeSubscriptionToken as jest.MockedFunction<
141145
typeof removeSubscriptionToken
@@ -4560,6 +4564,48 @@ describe('RewardsController', () => {
45604564
expect.anything(),
45614565
);
45624566
});
4567+
4568+
it('reports non-401 login errors to Sentry', async () => {
4569+
// Arrange — a 500 server error is unexpected and should be reported
4570+
const unexpectedError = new Error('Internal server error');
4571+
mockMessenger.call.mockClear();
4572+
mockStoreSubscriptionToken.mockResolvedValue({ success: true });
4573+
mockMessenger.call.mockImplementation(
4574+
(method: string, ..._args: unknown[]): any => {
4575+
if (method === 'AccountsController:listMultichainAccounts') {
4576+
return [mockInternalAccount];
4577+
}
4578+
if (method === 'RewardsDataService:getOptInStatus') {
4579+
return Promise.resolve({ ois: [true], sids: ['sub123'] });
4580+
}
4581+
if (method === 'KeyringController:signPersonalMessage') {
4582+
return Promise.resolve('0xsignature');
4583+
}
4584+
if (method === 'RewardsDataService:login') {
4585+
return Promise.reject(unexpectedError);
4586+
}
4587+
return Promise.resolve(undefined);
4588+
},
4589+
);
4590+
4591+
const testController = new RewardsController({
4592+
messenger: mockMessenger,
4593+
state: getRewardsControllerDefaultState(),
4594+
isDisabled: () => false,
4595+
});
4596+
4597+
// Act — call performSilentAuth directly with an opted-in account
4598+
await testController.performSilentAuth(mockInternalAccount, false, false);
4599+
4600+
// Assert — captureException called with the unexpected error
4601+
expect(mockCaptureException).toHaveBeenCalledWith(unexpectedError, {
4602+
tags: {
4603+
feature: 'rewards',
4604+
context: 'performSilentAuth.unexpected_error',
4605+
},
4606+
extra: { accountType: 'eip155:eoa' },
4607+
});
4608+
});
45634609
});
45644610

45654611
describe('getSeasonStatus', () => {
@@ -5230,6 +5276,14 @@ describe('RewardsController', () => {
52305276
expect(mockLogger.log).toHaveBeenCalledWith(
52315277
'RewardsController: Attempting reauth with active account after 403',
52325278
);
5279+
expect(mockCaptureException).toHaveBeenCalledWith(
5280+
expect.objectContaining({
5281+
message: expect.stringContaining('Reauth failed'),
5282+
}),
5283+
{
5284+
tags: { feature: 'rewards', context: 'withAuthRetry.reauth_failed' },
5285+
},
5286+
);
52335287
});
52345288

52355289
it('reauthenticates with active account and retries after 403 error', async () => {
@@ -10238,6 +10292,13 @@ describe('RewardsController', () => {
1023810292
await expect(controller.optIn(mockAccounts)).rejects.toThrow(
1023910293
'Failed to opt in any account from the account group',
1024010294
);
10295+
expect(mockCaptureException).toHaveBeenCalledWith(
10296+
expect.objectContaining({ message: 'Optin service error' }),
10297+
{
10298+
tags: { feature: 'rewards', context: 'optIn.unexpected_error' },
10299+
extra: { accountType: 'eip155:eoa' },
10300+
},
10301+
);
1024110302
});
1024210303

1024310304
it('handles signature generation errors', async () => {
@@ -11267,6 +11328,35 @@ describe('RewardsController', () => {
1126711328
expect(newState.subscriptions[subscriptionId2]).toBeDefined();
1126811329
expect(newState.subscriptions[subscriptionId2].id).toBe(subscriptionId2);
1126911330
});
11331+
11332+
it('reports unexpected opt-out failures to Sentry', async () => {
11333+
// Arrange — a non-403 server error is unexpected and should be captured
11334+
const unexpectedError = new Error('Internal server error');
11335+
mockMessenger.call.mockRejectedValue(unexpectedError);
11336+
11337+
const testController = new TestableRewardsController({
11338+
messenger: mockMessenger,
11339+
state: {
11340+
...getRewardsControllerDefaultState(),
11341+
subscriptions: {
11342+
sub123: {
11343+
id: 'sub123',
11344+
referralCode: 'REF123',
11345+
accounts: [],
11346+
},
11347+
},
11348+
},
11349+
});
11350+
11351+
// Act
11352+
const result = await testController.optOut('sub123');
11353+
11354+
// Assert
11355+
expect(result).toBe(false);
11356+
expect(mockCaptureException).toHaveBeenCalledWith(unexpectedError, {
11357+
tags: { feature: 'rewards', context: 'optOut.failed' },
11358+
});
11359+
});
1127011360
});
1127111361

1127211362
describe('optIn and optOut edge cases', () => {
@@ -13133,6 +13223,13 @@ describe('RewardsController', () => {
1313313223

1313413224
// Assert
1313513225
expect(result).toBe(false);
13226+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, {
13227+
tags: {
13228+
feature: 'rewards',
13229+
context: 'linkAccountToSubscriptionCandidate.failed',
13230+
},
13231+
extra: { accountType: 'eip155:eoa' },
13232+
});
1313613233
expect(mockLogger.log).toHaveBeenCalledWith(
1313713234
'RewardsController: Failed to link account to subscription',
1313813235
CAIP_ACCOUNT_1,

app/core/Engine/controllers/rewards-controller/RewardsController.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
getSubscriptionToken,
5757
} from './utils/multi-subscription-token-vault';
5858
import Logger from '../../../../util/Logger';
59+
import { captureException } from '@sentry/react-native';
5960
import type { InternalAccount } from '@metamask/keyring-internal-api';
6061
import { isAddress as isSolanaAddress } from '@solana/addresses';
6162
import { isHardwareAccount } from '../../../../util/address';
@@ -950,6 +951,9 @@ export class RewardsController extends BaseController<
950951
try {
951952
await this.#reauthPromises.get(subscriptionId);
952953
} catch (reauthError) {
954+
captureException(reauthError as Error, {
955+
tags: { feature: 'rewards', context: 'withAuthRetry.reauth_failed' },
956+
});
953957
this.invalidateSubscriptionCache(subscriptionId);
954958
await this.invalidateSubscriptionAndAccounts(subscriptionId);
955959
throw reauthError;
@@ -1314,6 +1318,13 @@ export class RewardsController extends BaseController<
13141318
// Unknown error
13151319
subscription = null;
13161320
authUnexpectedError = true;
1321+
captureException(error as Error, {
1322+
tags: {
1323+
feature: 'rewards',
1324+
context: 'performSilentAuth.unexpected_error',
1325+
},
1326+
extra: { accountType: internalAccount.type },
1327+
});
13171328
}
13181329
} finally {
13191330
// Update state
@@ -2541,6 +2552,10 @@ export class RewardsController extends BaseController<
25412552
sessionId: optinResponse.sessionId,
25422553
};
25432554
} catch (error) {
2555+
captureException(error as Error, {
2556+
tags: { feature: 'rewards', context: 'optIn.unexpected_error' },
2557+
extra: { accountType: account.type },
2558+
});
25442559
Logger.log(
25452560
'RewardsController: Opt-in failed for account',
25462561
account.address,
@@ -3059,6 +3074,13 @@ export class RewardsController extends BaseController<
30593074

30603075
return true;
30613076
} catch (error) {
3077+
captureException(error as Error, {
3078+
tags: {
3079+
feature: 'rewards',
3080+
context: 'linkAccountToSubscriptionCandidate.failed',
3081+
},
3082+
extra: { accountType: account.type },
3083+
});
30623084
Logger.log(
30633085
'RewardsController: Failed to link account to subscription',
30643086
caipAccount,
@@ -3178,6 +3200,9 @@ export class RewardsController extends BaseController<
31783200
);
31793201
return false;
31803202
} catch (error) {
3203+
captureException(error as Error, {
3204+
tags: { feature: 'rewards', context: 'optOut.failed' },
3205+
});
31813206
Logger.log('RewardsController: Failed to opt out', error);
31823207
return false;
31833208
}

0 commit comments

Comments
 (0)