Skip to content

Commit 8ecb0f9

Browse files
feat(perps): add MM Pay token metrics and cancel trade event tracking (#27109)
## **Description** This PR adds comprehensive event tracking for MM Pay token metrics in the Perps trading flow. The changes include: 1. **MM Pay Token Metrics Enhancement**: When users trade using their Perps balance (instead of paying with a token), the `mm_pay_token_selected` property now includes the value `'Perps Balance'` in trade transaction events. This provides complete visibility into payment method selection, whether users pay with tokens or use their Perps balance. 2. **Cancel Trade with Token Event Tracking**: Added event tracking for the cancel trade with token flow: - `PERPS_UI_INTERACTION` event with `interaction_type: 'cancel_trade_with_token'` when the user cancels a trade - `PERPS_SCREEN_VIEWED` event with `screen_type: 'cancel_trade_with_token_toast'` when the "taking longer" toast is displayed These metrics enable better analytics on user behavior, payment method preferences, and cancellation patterns in the Perps trading experience. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2616 ## **Manual testing steps** ```gherkin Feature: MM Pay token metrics and cancel trade tracking Scenario: user trades with Perps balance Given user has a Perps account with available balance When user places a trade order using Perps balance (not paying with a token) Then the PERPS_TRADE_TRANSACTION event should include mm_pay_token_selected: "Perps Balance" Scenario: user cancels trade with token during deposit Given user has initiated a trade order with token payment And the deposit is taking longer than expected When the "taking longer" toast appears Then the PERPS_SCREEN_VIEWED event should be tracked with screen_type: "cancel_trade_with_token_toast" And when user taps the cancel button Then the PERPS_UI_INTERACTION event should be tracked with interaction_type: "cancel_trade_with_token" ``` ## **Screenshots/Recordings** ### **Before** N/A (analytics/metrics changes, no UI changes) ### **After** N/A (analytics/metrics changes, no UI changes) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/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-mobile/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] > **Low Risk** > Low risk: changes are limited to MetaMetrics analytics properties/events and associated constants/tests, with no changes to order execution behavior beyond emitting additional tracking calls. > > **Overview** > Adds new Perps MetaMetrics tracking for the deposit+order cancellation flow: emits `PERPS_SCREEN_VIEWED` when the "deposit taking longer" cancel toast is shown and `PERPS_UI_INTERACTION` when the user taps cancel. > > Extends `PERPS_TRADE_TRANSACTION` analytics so when `trackingData` is present but `tradeWithToken` is false, `mm_pay_token_selected` is explicitly reported as `"Perps Balance"` (applied in both `usePerpsOrderExecution` and `TradingService`). Updates Perps event constants, tests, and the Perps metametrics reference docs accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9a82c62. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 348c47e commit 8ecb0f9

8 files changed

Lines changed: 236 additions & 4 deletions

File tree

app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { usePerpsOrderDepositTracking } from './usePerpsOrderDepositTracking';
88

99
const mockShowToast = jest.fn();
1010
const mockSubscribe = jest.fn();
11+
const mockTrack = jest.fn();
1112

1213
jest.mock('./usePerpsToasts', () => ({
1314
__esModule: true,
@@ -48,6 +49,24 @@ jest.mock('@metamask/perps-controller', () => ({
4849
PERPS_CONSTANTS: {
4950
DepositTakingLongerToastDelayMs: 100,
5051
},
52+
PERPS_EVENT_PROPERTY: {
53+
SCREEN_TYPE: 'screen_type',
54+
INTERACTION_TYPE: 'interaction_type',
55+
},
56+
PERPS_EVENT_VALUE: {
57+
SCREEN_TYPE: {
58+
CANCEL_TRADE_WITH_TOKEN_TOAST: 'cancel_trade_with_token_toast',
59+
},
60+
INTERACTION_TYPE: {
61+
CANCEL_TRADE_WITH_TOKEN: 'cancel_trade_with_token',
62+
},
63+
},
64+
}));
65+
66+
jest.mock('./usePerpsEventTracking', () => ({
67+
usePerpsEventTracking: () => ({
68+
track: mockTrack,
69+
}),
5170
}));
5271

5372
describe('usePerpsOrderDepositTracking', () => {
@@ -188,6 +207,13 @@ describe('usePerpsOrderDepositTracking', () => {
188207
closeButtonOptions?.closeButtonOptions?.onPress?.();
189208
});
190209

210+
expect(mockTrack).toHaveBeenCalledWith(
211+
expect.objectContaining({ category: 'Perp UI Interaction' }),
212+
expect.objectContaining({
213+
interaction_type: 'cancel_trade_with_token',
214+
}),
215+
);
216+
191217
const statusUpdatedHandler = handlers.statusUpdated;
192218
expect(statusUpdatedHandler).toBeDefined();
193219
act(() => {
@@ -260,6 +286,13 @@ describe('usePerpsOrderDepositTracking', () => {
260286
jest.advanceTimersByTime(100);
261287
});
262288

289+
expect(mockTrack).toHaveBeenCalledWith(
290+
expect.objectContaining({ category: 'Perp Screen Viewed' }),
291+
expect.objectContaining({
292+
screen_type: 'cancel_trade_with_token_toast',
293+
}),
294+
);
295+
263296
expect(mockShowToast).toHaveBeenCalled();
264297
expect(mockShowToast.mock.calls[0][0]).toMatchObject({
265298
closeButtonOptions: expect.any(Object),

app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import {
66
import { useCallback } from 'react';
77
import Engine from '../../../../core/Engine';
88
import { strings } from '../../../../../locales/i18n';
9-
import { PERPS_CONSTANTS } from '@metamask/perps-controller';
9+
import {
10+
PERPS_CONSTANTS,
11+
PERPS_EVENT_PROPERTY,
12+
PERPS_EVENT_VALUE,
13+
} from '@metamask/perps-controller';
1014
import usePerpsToasts from './usePerpsToasts';
15+
import { MetaMetricsEvents } from '../../../../core/Analytics';
16+
import { usePerpsEventTracking } from './usePerpsEventTracking';
1117

1218
/**
1319
* Hook to track deposit status for Perps order view
@@ -23,6 +29,7 @@ import usePerpsToasts from './usePerpsToasts';
2329
*/
2430
export const usePerpsOrderDepositTracking = () => {
2531
const { showToast, PerpsToastOptions } = usePerpsToasts();
32+
const { track } = usePerpsEventTracking();
2633

2734
const showProgressToast = useCallback(
2835
(transactionId: string) => {
@@ -57,11 +64,23 @@ export const usePerpsOrderDepositTracking = () => {
5764
PerpsToastOptions.accountManagement.deposit.takingLonger;
5865
const cancelTradeOnPress = () => {
5966
cancelTradeRequested = true;
67+
68+
track(MetaMetricsEvents.PERPS_UI_INTERACTION, {
69+
[PERPS_EVENT_PROPERTY.INTERACTION_TYPE]:
70+
PERPS_EVENT_VALUE.INTERACTION_TYPE.CANCEL_TRADE_WITH_TOKEN,
71+
});
72+
6073
// Replace current toast with "Trade canceled" (don't close first to avoid race)
6174
showToast(PerpsToastOptions.accountManagement.deposit.tradeCanceled);
6275
};
6376
const depositLongerTimeoutId = setTimeout(() => {
6477
const baseClose = takingLongerToastOptions.closeButtonOptions;
78+
79+
track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, {
80+
[PERPS_EVENT_PROPERTY.SCREEN_TYPE]:
81+
PERPS_EVENT_VALUE.SCREEN_TYPE.CANCEL_TRADE_WITH_TOKEN_TOAST,
82+
});
83+
6584
showToast({
6685
...takingLongerToastOptions,
6786
closeButtonOptions: baseClose
@@ -111,7 +130,12 @@ export const usePerpsOrderDepositTracking = () => {
111130
handleTransactionStatusUpdated,
112131
);
113132
},
114-
[showToast, showProgressToast, PerpsToastOptions.accountManagement.deposit],
133+
[
134+
showToast,
135+
showProgressToast,
136+
PerpsToastOptions.accountManagement.deposit,
137+
track,
138+
],
115139
);
116140

117141
return {

app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,47 @@ describe('usePerpsOrderExecution', () => {
208208
}),
209209
);
210210
});
211+
212+
it('tracks success with mm_pay_token_selected Perps Balance when trackingData has tradeWithToken false', async () => {
213+
const onSuccess = jest.fn();
214+
const paramsWithPerpsBalance: OrderParams = {
215+
...mockOrderParams,
216+
size: '0.2',
217+
trackingData: {
218+
totalFee: 0,
219+
marketPrice: 50000,
220+
tradeWithToken: false,
221+
},
222+
};
223+
224+
mockPlaceOrder.mockResolvedValue({
225+
success: true,
226+
orderId: 'order123',
227+
filledSize: '0.1',
228+
});
229+
mockGetPositions.mockResolvedValue([mockPosition]);
230+
231+
const { result } = renderHook(() =>
232+
usePerpsOrderExecution({ onSuccess, onError: jest.fn() }),
233+
);
234+
235+
await act(async () => {
236+
await result.current.placeOrder(paramsWithPerpsBalance);
237+
});
238+
239+
await waitFor(() => {
240+
expect(result.current.isPlacing).toBe(false);
241+
});
242+
243+
expect(mockTrack).toHaveBeenCalledWith(
244+
MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
245+
expect.objectContaining({
246+
[PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false,
247+
[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]:
248+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE,
249+
}),
250+
);
251+
});
211252
});
212253

213254
describe('failed order placement', () => {
@@ -280,6 +321,43 @@ describe('usePerpsOrderExecution', () => {
280321
);
281322
});
282323

324+
it('tracks failed order with mm_pay_token_selected Perps Balance when trackingData has tradeWithToken false', async () => {
325+
const onError = jest.fn();
326+
const paramsWithPerpsBalance: OrderParams = {
327+
...mockOrderParams,
328+
trackingData: {
329+
totalFee: 0,
330+
marketPrice: 50000,
331+
tradeWithToken: false,
332+
},
333+
};
334+
335+
mockPlaceOrder.mockResolvedValue({
336+
success: false,
337+
error: 'Insufficient margin',
338+
});
339+
340+
const { result } = renderHook(() => usePerpsOrderExecution({ onError }));
341+
342+
await act(async () => {
343+
await result.current.placeOrder(paramsWithPerpsBalance);
344+
});
345+
346+
await waitFor(() => {
347+
expect(result.current.isPlacing).toBe(false);
348+
});
349+
350+
expect(mockTrack).toHaveBeenCalledWith(
351+
MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
352+
expect.objectContaining({
353+
[PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED,
354+
[PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false,
355+
[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]:
356+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE,
357+
}),
358+
);
359+
});
360+
283361
it('calls onError with unknown error when order returns success false without error', async () => {
284362
const onError = jest.fn();
285363

@@ -355,6 +433,40 @@ describe('usePerpsOrderExecution', () => {
355433
}),
356434
);
357435
});
436+
437+
it('tracks exception with mm_pay_token_selected Perps Balance when placeOrder rejects and trackingData has tradeWithToken false', async () => {
438+
const onError = jest.fn();
439+
const paramsWithPerpsBalance: OrderParams = {
440+
...mockOrderParams,
441+
trackingData: {
442+
totalFee: 0,
443+
marketPrice: 50000,
444+
tradeWithToken: false,
445+
},
446+
};
447+
448+
mockPlaceOrder.mockRejectedValue(new Error('Network timeout'));
449+
450+
const { result } = renderHook(() => usePerpsOrderExecution({ onError }));
451+
452+
await act(async () => {
453+
await result.current.placeOrder(paramsWithPerpsBalance);
454+
});
455+
456+
await waitFor(() => {
457+
expect(result.current.isPlacing).toBe(false);
458+
});
459+
460+
expect(mockTrack).toHaveBeenCalledWith(
461+
MetaMetricsEvents.PERPS_TRADE_TRANSACTION,
462+
expect.objectContaining({
463+
[PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED,
464+
[PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false,
465+
[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]:
466+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE,
467+
}),
468+
);
469+
});
358470
});
359471

360472
describe('error state management', () => {

app/components/UI/Perps/hooks/usePerpsOrderExecution.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ export function usePerpsOrderExecution(
109109
partialProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] =
110110
orderParams.trackingData.mmPayNetworkSelected;
111111
}
112+
} else if (orderParams.trackingData !== undefined) {
113+
partialProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] =
114+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE;
112115
}
113116
track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, partialProps);
114117
}
@@ -172,6 +175,9 @@ export function usePerpsOrderExecution(
172175
failedProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] =
173176
orderParams.trackingData.mmPayNetworkSelected;
174177
}
178+
} else if (orderParams.trackingData !== undefined) {
179+
failedProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] =
180+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE;
175181
}
176182
track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, failedProps);
177183

@@ -228,6 +234,9 @@ export function usePerpsOrderExecution(
228234
exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] =
229235
orderParams.trackingData.mmPayNetworkSelected;
230236
}
237+
} else if (orderParams.trackingData !== undefined) {
238+
exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] =
239+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE;
231240
}
232241
track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, exceptionProps);
233242

app/controllers/perps/constants/eventNames.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ export const PERPS_EVENT_VALUE = {
305305
// Pay-with interactions
306306
PAYMENT_TOKEN_SELECTOR: 'payment_token_selector',
307307
PAYMENT_METHOD_CHANGED: 'payment_method_changed',
308+
// Deposit + order (pay-with token) cancel
309+
CANCEL_TRADE_WITH_TOKEN: 'cancel_trade_with_token',
308310
},
309311
ACTION_TYPE: {
310312
START_TRADING: 'start_trading',
@@ -376,6 +378,8 @@ export const PERPS_EVENT_VALUE = {
376378
ADD_MARGIN: 'add_margin',
377379
REMOVE_MARGIN: 'remove_margin',
378380
GEO_BLOCK_NOTIF: 'geo_block_notif',
381+
// Deposit + order (pay-with token) cancel toast
382+
CANCEL_TRADE_WITH_TOKEN_TOAST: 'cancel_trade_with_token_toast',
379383
},
380384
SETTING_TYPE: {
381385
LEVERAGE: 'leverage',
@@ -413,6 +417,10 @@ export const PERPS_EVENT_VALUE = {
413417
FUNDING: 'funding',
414418
DEPOSITS: 'deposits',
415419
},
420+
/** Value for mm_pay_token_selected when user pays with Perps balance (not a token) */
421+
MM_PAY_TOKEN: {
422+
PERPS_BALANCE: 'Perps Balance',
423+
},
416424
// A/B testing values
417425
AB_TEST: {
418426
// Test IDs

app/controllers/perps/services/TradingService.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createMockPerpsControllerState,
55
createMockInfrastructure,
66
} from '../../../components/UI/Perps/__mocks__/serviceMocks';
7+
import { PERPS_EVENT_VALUE } from '../constants/eventNames';
78
import { PerpsAnalyticsEvent } from '../types';
89
import type {
910
PerpsProvider,
@@ -322,6 +323,48 @@ describe('TradingService', () => {
322323
);
323324
});
324325

326+
it('includes mm_pay_token_selected "Perps Balance" when user uses perps balance', async () => {
327+
const orderParams: OrderParams = {
328+
symbol: 'BTC',
329+
isBuy: true,
330+
size: '0.1',
331+
orderType: 'market',
332+
leverage: 10,
333+
trackingData: {
334+
totalFee: 0,
335+
marketPrice: 50000,
336+
tradeWithToken: false,
337+
},
338+
};
339+
const mockOrderResult: OrderResult = {
340+
success: true,
341+
orderId: 'order-123',
342+
filledSize: '0.1',
343+
averagePrice: '50000',
344+
};
345+
346+
mockProvider.placeOrder.mockResolvedValue(mockOrderResult);
347+
mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue(
348+
undefined,
349+
);
350+
351+
await tradingService.placeOrder({
352+
provider: mockProvider,
353+
params: orderParams,
354+
context: mockContext,
355+
reportOrderToDataLake: mockReportOrderToDataLake,
356+
});
357+
358+
expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith(
359+
PerpsAnalyticsEvent.TradeTransaction,
360+
expect.objectContaining({
361+
status: 'executed',
362+
trade_with_token: false,
363+
mm_pay_token_selected: PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE,
364+
}),
365+
);
366+
});
367+
325368
it('tracks analytics event when order fails', async () => {
326369
const orderParams: OrderParams = {
327370
symbol: 'BTC',

app/controllers/perps/services/TradingService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export class TradingService {
164164
if (params.trackingData?.tradeAction) {
165165
properties[PERPS_EVENT_PROPERTY.ACTION] = params.trackingData.tradeAction;
166166
}
167-
// Pay with any token: trade_with_token (boolean); when true, include mm_pay_token_selected and mm_pay_network_selected
167+
// Pay with any token: trade_with_token (boolean); when true, include mm_pay_token_selected and mm_pay_network_selected; when false (Perps balance), include mm_pay_token_selected: "Perps Balance"
168168
properties[PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN] =
169169
params.trackingData?.tradeWithToken === true;
170170
if (params.trackingData?.tradeWithToken === true) {
@@ -176,6 +176,9 @@ export class TradingService {
176176
properties[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] =
177177
params.trackingData.mmPayNetworkSelected;
178178
}
179+
} else if (params.trackingData !== undefined) {
180+
properties[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] =
181+
PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE;
179182
}
180183

181184
// Add success-specific properties

docs/perps/perps-metametrics-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, {
162162
- `input_method` (optional): How value was entered: `'slider' | 'keyboard' | 'preset' | 'manual' | 'percentage_button'`
163163
- `limit_price` (optional): Limit order price (for limit orders) (number)
164164
- `trade_with_token` (optional): Whether the user paid with a token other than Perps balance (boolean)
165-
- `mm_pay_token_selected` (optional): Token symbol selected for pay-with (e.g. `'USDC'`), included when `trade_with_token` is true
165+
- `mm_pay_token_selected` (optional): Token symbol selected for pay-with (e.g. `'USDC'`); when `trade_with_token` is true, the selected token symbol; when user uses Perps balance, `'Perps Balance'`
166166
- `mm_pay_network_selected` (optional): Network/chain for pay-with (e.g. `'ethereum'`), included when `trade_with_token` is true
167167
- `error_message` (optional): Error description when status is 'failed'
168168

0 commit comments

Comments
 (0)