Skip to content

Commit 4e83735

Browse files
feat: add metrics for shield-rewards integration (#38567)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/38567?quickstart=1) ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: add metrics for shield-rewards integration ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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 - [x] 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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > Adds Shield "Opt In Rewards" metric and wires rewardPoints/subscriptionId through plan/settings/onboarding to track on subscription creation or linking; updates service, actions, types, UI, and tests accordingly. > > - **Shield metrics & service**: > - Add `MetaMetricsEventName.ShieldOptInRewards` and `#trackShieldOptInRewardsEvent` to emit metrics with `rewards_point` and `rewards_opt_in_type`. > - Track on card subscription completion (`create_new_subscription`) and when linking rewards (`link_existing_subscription`). > - Change `linkRewardToExistingSubscription` to `linkRewardToExistingSubscription(subscriptionId, rewardPoints)` and call `SubscriptionController:linkRewards`. > - **Types & state**: > - Extend `ShieldSubscriptionMetricsPropsFromUI` with optional `rewardPoints` and forward via `setShieldSubscriptionMetricsProps`. > - **UI wiring**: > - `OnboardingModal`/`OnboardingStep4` accept `rewardPoints` and `shieldSubscriptionId`; `useOptIn` optionally dispatches `linkRewardToShieldSubscription` after opt-in. > - `useHandleSubscription` accepts `rewardPoints` and sets it in background metrics props. > - `transaction-shield.tsx` and `shield-plan.tsx`: compute `claimedRewardsPoints`, display text, and pass to onboarding/modal/handlers; minor hook var rename for opt-in status. > - **Actions**: > - Add `linkRewardToShieldSubscription(subscriptionId, rewardPoints)` thunk. > - **Tests**: > - Update `subscription-service.test.ts` to cover new `rewardPoints` param and behaviors. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f0cac6a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: MetaMask Bot <[email protected]>
1 parent 85f4650 commit 4e83735

File tree

12 files changed

+162
-25
lines changed

12 files changed

+162
-25
lines changed

app/scripts/services/subscription/subscription-service.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ describe('SubscriptionService - linkRewardToExistingSubscription', () => {
484484
const MOCK_REWARD_ACCOUNT_ID =
485485
'eip155:0:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc';
486486
const MOCK_SHIELD_SUBSCRIPTION_ID = 'shield_subscription_id';
487+
const MOCK_REWARD_POINTS = 100;
487488

488489
beforeEach(() => {
489490
jest.resetAllMocks();
@@ -518,6 +519,7 @@ describe('SubscriptionService - linkRewardToExistingSubscription', () => {
518519
it('should link the reward to the existing subscription', async () => {
519520
await subscriptionService.linkRewardToExistingSubscription(
520521
MOCK_SHIELD_SUBSCRIPTION_ID,
522+
MOCK_REWARD_POINTS,
521523
);
522524

523525
expect(mockLinkRewards).toHaveBeenCalledWith({
@@ -539,6 +541,7 @@ describe('SubscriptionService - linkRewardToExistingSubscription', () => {
539541
});
540542
await subscriptionService.linkRewardToExistingSubscription(
541543
MOCK_SHIELD_SUBSCRIPTION_ID,
544+
MOCK_REWARD_POINTS,
542545
);
543546

544547
expect(mockLinkRewards).not.toHaveBeenCalled();
@@ -550,6 +553,7 @@ describe('SubscriptionService - linkRewardToExistingSubscription', () => {
550553

551554
await subscriptionService.linkRewardToExistingSubscription(
552555
MOCK_SHIELD_SUBSCRIPTION_ID,
556+
MOCK_REWARD_POINTS,
553557
);
554558

555559
expect(mockLinkRewards).not.toHaveBeenCalled();

app/scripts/services/subscription/subscription-service.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ export class SubscriptionService {
173173
'SubscriptionController:getSubscriptions',
174174
);
175175
this.trackSubscriptionRequestEvent('completed');
176+
177+
// Track the shield opt in rewards event if the reward account id and reward points are provided
178+
if (rewardAccountId) {
179+
this.#trackShieldOptInRewardsEvent('create_new_subscription');
180+
}
176181
return subscriptions;
177182
} catch (error) {
178183
const errorMessage =
@@ -244,9 +249,13 @@ export class SubscriptionService {
244249
* Link the reward to the existing shield subscription.
245250
*
246251
* @param subscriptionId - Shield subscription ID to link the reward to.
252+
* @param rewardPoints - The reward points.
247253
* @returns Promise<void> - The reward subscription ID or undefined if the season is not active or the primary account is not opted in to rewards.
248254
*/
249-
async linkRewardToExistingSubscription(subscriptionId: string) {
255+
async linkRewardToExistingSubscription(
256+
subscriptionId: string,
257+
rewardPoints: number,
258+
) {
250259
try {
251260
const rewardAccountId = await this.#getRewardCaipAccountId();
252261
if (!rewardAccountId) {
@@ -257,6 +266,13 @@ export class SubscriptionService {
257266
subscriptionId,
258267
rewardAccountId,
259268
});
269+
270+
if (rewardAccountId && rewardPoints) {
271+
this.#trackShieldOptInRewardsEvent(
272+
'link_existing_subscription',
273+
rewardPoints,
274+
);
275+
}
260276
} catch (error) {
261277
log.error('Failed to link reward to existing subscription', error);
262278
}
@@ -596,4 +612,40 @@ export class SubscriptionService {
596612
log.error('Failed to assign post tx cohort', error);
597613
}
598614
}
615+
616+
#trackShieldOptInRewardsEvent(
617+
rewardsOptInType: 'create_new_subscription' | 'link_existing_subscription',
618+
rewardPoints?: number,
619+
) {
620+
const accountTypeAndCategory = this.#getAccountTypeAndCategoryForMetrics();
621+
622+
const { shieldSubscriptionMetricsProps } = this.#messenger.call(
623+
'AppStateController:getState',
624+
);
625+
626+
const claimedRewardPoints =
627+
rewardPoints ?? shieldSubscriptionMetricsProps?.rewardPoints;
628+
if (!claimedRewardPoints) {
629+
return;
630+
}
631+
632+
this.#messenger.call('MetaMetricsController:trackEvent', {
633+
event: MetaMetricsEventName.ShieldOptInRewards,
634+
category: MetaMetricsEventCategory.Shield,
635+
properties: {
636+
...accountTypeAndCategory,
637+
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
638+
// eslint-disable-next-line @typescript-eslint/naming-convention
639+
multi_chain_balance_category: getUserBalanceCategory(
640+
shieldSubscriptionMetricsProps?.userBalanceInUSD ?? 0,
641+
),
642+
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
643+
// eslint-disable-next-line @typescript-eslint/naming-convention
644+
rewards_point: claimedRewardPoints,
645+
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
646+
// eslint-disable-next-line @typescript-eslint/naming-convention
647+
rewards_opt_in_type: rewardsOptInType,
648+
},
649+
});
650+
}
599651
}

shared/constants/metametrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,7 @@ export enum MetaMetricsEventName {
10031003
ShieldEligibilityCohortAssigned = 'Shield Eligibility Cohort Assigned',
10041004
ShieldEligibilityCohortTimeout = 'Shield Eligibility Cohort Timeout',
10051005
ShieldSubscriptionUnexpectedErrorEvent = 'Shield Subscription Unexpected Error',
1006+
ShieldOptInRewards = 'Shield Opt In Rewards',
10061007
}
10071008

10081009
export enum MetaMetricsEventAccountType {

shared/types/metametrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ export type DefaultSubscriptionPaymentOptions = {
103103
export type ShieldSubscriptionMetricsPropsFromUI = {
104104
userBalanceInUSD: number;
105105
source: EntryModalSourceEnum;
106+
rewardPoints?: number;
106107
marketingUtmParams?: Record<string, string>;
107108
};

ui/components/app/rewards/onboarding/OnboardingModal.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,26 @@ import OnboardingStep2 from './OnboardingStep2';
3434
import OnboardingStep3 from './OnboardingStep3';
3535
import OnboardingStep4 from './OnboardingStep4';
3636

37+
type OnboardingModalProps = {
38+
onClose?: () => void;
39+
40+
/**
41+
* The number of reward points which user will receive after linking the reward to the shield subscription.
42+
*/
43+
rewardPoints?: number;
44+
45+
/**
46+
* The shield subscription ID to link the reward to.
47+
*/
48+
shieldSubscriptionId?: string;
49+
};
50+
3751
// eslint-disable-next-line @typescript-eslint/naming-convention
38-
export default function OnboardingModal({ onClose }: { onClose?: () => void }) {
52+
export default function OnboardingModal({
53+
onClose,
54+
rewardPoints,
55+
shieldSubscriptionId,
56+
}: OnboardingModalProps) {
3957
const isOpen = useSelector(selectOnboardingModalOpen);
4058
const onboardingStep = useSelector(selectOnboardingActiveStep);
4159
const candidateSubscriptionId = useSelector(selectCandidateSubscriptionId);
@@ -77,14 +95,21 @@ export default function OnboardingModal({ onClose }: { onClose?: () => void }) {
7795
case OnboardingStep.STEP3:
7896
return <OnboardingStep3 />;
7997
case OnboardingStep.STEP4:
80-
return <OnboardingStep4 />;
98+
return (
99+
<OnboardingStep4
100+
rewardPoints={rewardPoints}
101+
shieldSubscriptionId={shieldSubscriptionId}
102+
/>
103+
);
81104
default:
82105
return <OnboardingIntroStep />;
83106
}
84107
}, [
85108
isValidCandidateSubscriptionId,
86109
onboardingStep,
87110
rewardActiveAccountSubscriptionId,
111+
rewardPoints,
112+
shieldSubscriptionId,
88113
]);
89114

90115
useEffect(() => {

ui/components/app/rewards/onboarding/OnboardingStep4.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,21 @@ import {
3434
} from './constants';
3535
import ProgressIndicator from './ProgressIndicator';
3636

37-
const OnboardingStep4: React.FC = () => {
37+
type OnboardingStep4Props = {
38+
rewardPoints?: number;
39+
shieldSubscriptionId?: string;
40+
};
41+
42+
const OnboardingStep4: React.FC<OnboardingStep4Props> = ({
43+
rewardPoints,
44+
shieldSubscriptionId,
45+
}) => {
3846
const t = useI18nContext();
3947

40-
const { optinLoading, optinError, optin } = useOptIn();
48+
const { optinLoading, optinError, optin } = useOptIn({
49+
rewardPoints,
50+
shieldSubscriptionId,
51+
});
4152
const onboardingReferralCode = useSelector(selectOnboardingReferralCode);
4253

4354
const {

ui/hooks/rewards/useOptIn.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
rewardsOptIn,
2020
rewardsLinkAccountsToSubscriptionCandidate,
2121
updateMetaMetricsTraits,
22+
linkRewardToShieldSubscription,
2223
} from '../../store/actions';
2324
import { handleRewardsErrorMessage } from '../../components/app/rewards/utils/handleRewardsErrorMessage';
2425
import { useI18nContext } from '../useI18nContext';
@@ -44,7 +45,12 @@ export type UseOptinResult = {
4445
clearOptinError: () => void;
4546
};
4647

47-
export const useOptIn = (): UseOptinResult => {
48+
type UseOptInOptions = {
49+
rewardPoints?: number;
50+
shieldSubscriptionId?: string;
51+
};
52+
53+
export const useOptIn = (options?: UseOptInOptions): UseOptinResult => {
4854
const [optinError, setOptinError] = useState<string | null>(null);
4955
const dispatch = useDispatch();
5056
const [optinLoading, setOptinLoading] = useState<boolean>(false);
@@ -157,6 +163,20 @@ export const useOptIn = (): UseOptinResult => {
157163
} catch {
158164
// Silently fail - traits update should not block opt-in
159165
}
166+
167+
// Link the reward to the shield subscription if opt in from the shield subscription
168+
if (options?.rewardPoints && options?.shieldSubscriptionId) {
169+
try {
170+
await dispatch(
171+
linkRewardToShieldSubscription(
172+
options.shieldSubscriptionId,
173+
options.rewardPoints,
174+
),
175+
);
176+
} catch {
177+
// Silently fail - reward linking should not block opt-in
178+
}
179+
}
160180
}
161181
} catch (error) {
162182
trackEvent({
@@ -182,6 +202,8 @@ export const useOptIn = (): UseOptinResult => {
182202
activeGroupAccounts,
183203
dispatch,
184204
t,
205+
options?.rewardPoints,
206+
options?.shieldSubscriptionId,
185207
],
186208
);
187209

ui/hooks/shield/metrics/useSubscriptionMetrics.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,14 @@ export const useSubscriptionMetrics = () => {
6565
async (props: {
6666
marketingUtmParams?: Record<string, string>;
6767
source: EntryModalSourceEnum;
68+
rewardPoints?: number;
6869
}) => {
6970
await dispatch(
7071
setShieldSubscriptionMetricsProps({
7172
marketingUtmParams: props.marketingUtmParams,
7273
source: props.source,
7374
userBalanceInUSD: Number(totalFiatBalance),
75+
rewardPoints: props.rewardPoints,
7476
}),
7577
);
7678
},

ui/hooks/subscription/useSubscription.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ export const useSubscriptionEligibility = (product: ProductType) => {
471471
* @param options.defaultOptions - The default options for the subscription request.
472472
* @param options.isTrialed - Whether the user is trialing the subscription.
473473
* @param options.useTestClock - Whether to use a test clock for the subscription.
474+
* @param options.rewardPoints
474475
* @returns An object with the handleSubscription function and the subscription result.
475476
*/
476477
export const useHandleSubscription = ({
@@ -480,6 +481,7 @@ export const useHandleSubscription = ({
480481
defaultOptions,
481482
isTrialed,
482483
useTestClock = false,
484+
rewardPoints,
483485
}: {
484486
defaultOptions: DefaultSubscriptionPaymentOptions;
485487
subscriptionState?: SubscriptionStatus;
@@ -488,6 +490,7 @@ export const useHandleSubscription = ({
488490
isTrialed: boolean;
489491
selectedToken?: TokenWithApprovalAmount;
490492
useTestClock?: boolean;
493+
rewardPoints?: number;
491494
}) => {
492495
const dispatch = useDispatch<MetaMaskReduxDispatch>();
493496
const { search } = useLocation();
@@ -550,6 +553,7 @@ export const useHandleSubscription = ({
550553
await setShieldSubscriptionMetricsPropsToBackground({
551554
source: determineSubscriptionRequestSource(),
552555
marketingUtmParams,
556+
rewardPoints,
553557
});
554558

555559
const source = determineSubscriptionRequestSource();
@@ -619,6 +623,7 @@ export const useHandleSubscription = ({
619623
modalType,
620624
determineSubscriptionRequestSource,
621625
search,
626+
rewardPoints,
622627
]);
623628

624629
return {
@@ -779,14 +784,17 @@ export const useShieldRewards = (): {
779784
}, [primaryKeyring, accountsWithCaipChainId]);
780785

781786
const {
782-
value: hasAccountOptedIn,
787+
value: hasAccountOptedInResultValue,
783788
pending: hasAccountOptedInResultPending,
784789
error: hasAccountOptedInResultError,
785790
} = useAsyncResult<boolean>(async () => {
786791
if (!caipAccountId) {
787792
return false;
788793
}
789-
return await dispatch(getRewardsHasAccountOptedIn(caipAccountId));
794+
const optinStatus = await dispatch(
795+
getRewardsHasAccountOptedIn(caipAccountId),
796+
);
797+
return optinStatus;
790798
}, [caipAccountId]);
791799

792800
const {
@@ -861,6 +869,7 @@ export const useShieldRewards = (): {
861869
if (hasAccountOptedInResultError) {
862870
console.error('[useShieldRewards error]:', hasAccountOptedInResultError);
863871
}
872+
864873
return {
865874
pending: false,
866875
pointsMonthly: null,
@@ -875,6 +884,6 @@ export const useShieldRewards = (): {
875884
pointsMonthly: pointsValue?.monthly ?? null,
876885
pointsYearly: pointsValue?.yearly ?? null,
877886
isRewardsSeason: isRewardsSeason ?? false,
878-
hasAccountOptedIn: hasAccountOptedIn ?? false,
887+
hasAccountOptedIn: hasAccountOptedInResultValue ?? false,
879888
};
880889
};

ui/pages/settings/transaction-shield-tab/transaction-shield.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -245,24 +245,21 @@ const TransactionShield = () => {
245245
dispatch(setOnboardingModalOpen(true));
246246
}, [dispatch]);
247247

248-
const formattedRewardsPoints = useMemo(() => {
248+
const claimedRewardsPoints = useMemo(() => {
249249
const points =
250250
displayedShieldSubscription?.interval === RECURRING_INTERVALS.year
251251
? pointsYearly
252252
: pointsMonthly;
253+
return points;
254+
}, [pointsYearly, pointsMonthly, displayedShieldSubscription?.interval]);
253255

254-
if (!points || !isRewardsSeason) {
256+
const formattedRewardsPoints = useMemo(() => {
257+
if (!claimedRewardsPoints || !isRewardsSeason) {
255258
return '';
256259
}
257260

258-
return new Intl.NumberFormat(locale).format(points);
259-
}, [
260-
displayedShieldSubscription?.interval,
261-
pointsYearly,
262-
pointsMonthly,
263-
isRewardsSeason,
264-
locale,
265-
]);
261+
return new Intl.NumberFormat(locale).format(claimedRewardsPoints);
262+
}, [claimedRewardsPoints, isRewardsSeason, locale]);
266263

267264
const shieldDetails = [
268265
{
@@ -849,7 +846,10 @@ const TransactionShield = () => {
849846
}
850847
/>
851848
)}
852-
<RewardsOnboardingModal />
849+
<RewardsOnboardingModal
850+
rewardPoints={claimedRewardsPoints ?? undefined}
851+
shieldSubscriptionId={displayedShieldSubscription?.id}
852+
/>
853853
</Box>
854854
);
855855
};

0 commit comments

Comments
 (0)