Skip to content

Commit 8276fc8

Browse files
authored
feat(swaps): add post-trade modal analytics (MetaMask#31699)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> <!-- mms-check directive vocabulary — read by .github/scripts/shared/pr-template-checks.ts at module load to build the validation plan. Directives are invisible in rendered markdown and must NOT be removed or edited without updating the validator registry. type=text Section must contain non-placeholder prose. type=changelog Section must have a valid CHANGELOG entry: line. type=issue-link Section must have a Fixes:/Closes:/Refs: line with a value. type=manual-testing Section must have real testing steps or an explicit N/A. type=screenshot Section must have evidence (image/URL) or an explicit N/A. type=checklist Section must have all checkboxes consciously checked. required=true|false Whether a missing/invalid section runs the validator at all. blocking=true|false Whether a failure of this check fails the CI workflow. Default: false — failures are shown as warnings in the sticky comment but do not block the PR. Sections without a directive are checked for structural presence only. --> ## **Description** <!-- mms-check: type=text required=true --> <!-- 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? --> Adds analytics coverage for the unified swaps/bridge post-trade status modal so the funnel can measure when users see the modal, dismiss it, or click a modal CTA after a trade. This adds MetaMetrics event definitions for viewed, dismissed, and button clicked events; normalizes post-trade statuses to `in_progress`, `complete`, and `failed`; includes shared swap/bridge token and chain properties; records modal open duration; and prevents CTA interactions from also being counted as dismissals. It also tracks the merged post-trade trending-token suggestion CTA as `trending_token` with the clicked token metadata. ## **Changelog** <!-- mms-check: type=changelog required=true blocking=true --> <!-- 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: null ## **Related issues** <!-- mms-check: type=issue-link required=true --> Refs: [SWAPS-4569](https://consensyssoftware.atlassian.net/browse/SWAPS-4569) ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> N/A - analytics-only change covered by focused unit tests. ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> [SWAPS-4569]: https://consensyssoftware.atlassian.net/browse/SWAPS-4569?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 91710ba commit 8276fc8

4 files changed

Lines changed: 274 additions & 8 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getDecimalChainId } from '../../../../../util/networks';
2+
import {
3+
PostTradeBottomSheetParams,
4+
PostTradeStatus,
5+
} from './PostTradeBottomSheet.types';
6+
7+
export type PostTradeAnalyticsStatus = 'in_progress' | 'complete' | 'failed';
8+
export type PostTradeAnalyticsCta =
9+
| 'view_activity'
10+
| 'try_again'
11+
| 'trending_token';
12+
13+
export const getAnalyticsStatus = (
14+
status: PostTradeStatus,
15+
): PostTradeAnalyticsStatus => {
16+
if (status === PostTradeStatus.Success) {
17+
return 'complete';
18+
}
19+
20+
if (status === PostTradeStatus.Failed) {
21+
return 'failed';
22+
}
23+
24+
return 'in_progress';
25+
};
26+
27+
export const getPostTradeSharedAnalyticsProperties = ({
28+
sourceToken,
29+
destToken,
30+
}: PostTradeBottomSheetParams) => ({
31+
swap_type:
32+
sourceToken?.chainId &&
33+
destToken?.chainId &&
34+
sourceToken.chainId !== destToken.chainId
35+
? 'crosschain'
36+
: 'single_chain',
37+
chain_id_source: getDecimalChainId(sourceToken?.chainId),
38+
chain_id_destination: getDecimalChainId(destToken?.chainId),
39+
token_symbol_source: sourceToken?.symbol,
40+
token_symbol_destination: destToken?.symbol,
41+
token_address_source: sourceToken?.address,
42+
token_address_destination: destToken?.address,
43+
});

app/components/UI/Bridge/components/PostTradeBottomSheet/PostTradeBottomSheet.test.ts

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { fireEvent, render } from '@testing-library/react-native';
33
import { PostTradeBottomSheet } from './index';
4-
import Routes from '../../../../../constants/navigation/Routes';
4+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
55
import {
66
getPostTradeSuggestionPillTestId,
77
PostTradeBottomSheetTestIds,
@@ -12,6 +12,13 @@ const mockDispatch = jest.fn();
1212
const mockNavigate = jest.fn();
1313
const mockResetState = jest.fn();
1414
const mockUpdateQuoteParams = jest.fn();
15+
const mockTrackEvent = jest.fn();
16+
const mockCreateEventBuilder = jest.fn((event) => ({
17+
addProperties: (properties: Record<string, unknown>) => ({
18+
build: () => ({ event, properties }),
19+
}),
20+
}));
21+
let mockNow = 1000;
1522
let mockPostTradeStatus = PostTradeStatus.Failed;
1623
let mockPostTradeTrendingTokens = {
1724
tokens: [] as unknown[],
@@ -36,8 +43,23 @@ let mockParams = {
3643
decimals: 6,
3744
},
3845
};
46+
const expectedSharedProperties = {
47+
swap_type: 'single_chain',
48+
chain_id_source: '1',
49+
chain_id_destination: '1',
50+
token_symbol_source: 'ETH',
51+
token_symbol_destination: 'USDC',
52+
token_address_source: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
53+
token_address_destination: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
54+
};
3955

4056
jest.mock('react-redux', () => ({ useDispatch: () => mockDispatch }));
57+
jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
58+
useAnalytics: () => ({
59+
trackEvent: mockTrackEvent,
60+
createEventBuilder: mockCreateEventBuilder,
61+
}),
62+
}));
4163
jest.mock('../../hooks/useBridgeQuoteRequest', () => ({
4264
useBridgeQuoteRequest: () => mockUpdateQuoteParams,
4365
}));
@@ -62,6 +84,8 @@ jest.mock('./usePostTradeTrendingTokens', () => ({
6284

6385
beforeEach(() => {
6486
jest.clearAllMocks();
87+
mockNow = 1000;
88+
jest.spyOn(Date, 'now').mockImplementation(() => mockNow);
6589
mockPostTradeStatus = PostTradeStatus.Failed;
6690
mockPostTradeTrendingTokens = {
6791
tokens: [],
@@ -88,8 +112,61 @@ beforeEach(() => {
88112
};
89113
});
90114

115+
afterEach(() => {
116+
jest.restoreAllMocks();
117+
});
118+
119+
const getTrackedEvent = (event: unknown) =>
120+
mockTrackEvent.mock.calls.find(
121+
([trackedEvent]) => trackedEvent.event === event,
122+
)?.[0];
123+
91124
describe('PostTradeBottomSheet', () => {
92-
it('runs failed-state actions', () => {
125+
it('tracks viewed with normalized status and trade properties', () => {
126+
mockPostTradeStatus = PostTradeStatus.Success;
127+
mockParams = {
128+
...mockParams,
129+
status: PostTradeStatus.Success,
130+
destToken: {
131+
...mockParams.destToken,
132+
chainId: '0x89',
133+
},
134+
};
135+
136+
render(React.createElement(PostTradeBottomSheet));
137+
138+
expect(
139+
getTrackedEvent(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_VIEWED),
140+
).toEqual({
141+
event: MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_VIEWED,
142+
properties: {
143+
initial_status: 'complete',
144+
...expectedSharedProperties,
145+
swap_type: 'crosschain',
146+
chain_id_destination: '137',
147+
},
148+
});
149+
});
150+
151+
it('tracks dismissed when the modal closes', () => {
152+
const { getByTestId } = render(React.createElement(PostTradeBottomSheet));
153+
154+
mockNow = 1250;
155+
fireEvent.press(getByTestId(PostTradeBottomSheetTestIds.CLOSE_BUTTON));
156+
157+
expect(
158+
getTrackedEvent(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_DISMISSED),
159+
).toEqual({
160+
event: MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_DISMISSED,
161+
properties: {
162+
status_at_dismissal: 'failed',
163+
time_modal_open_ms: 250,
164+
...expectedSharedProperties,
165+
},
166+
});
167+
});
168+
169+
it('tracks CTA clicks without double-counting dismissal', () => {
93170
const { getByTestId, queryByTestId } = render(
94171
React.createElement(PostTradeBottomSheet),
95172
);
@@ -98,12 +175,23 @@ describe('PostTradeBottomSheet', () => {
98175
queryByTestId(PostTradeBottomSheetTestIds.SUGGESTIONS_SECTION),
99176
).toBeNull();
100177

178+
mockNow = 1400;
101179
fireEvent.press(getByTestId(PostTradeBottomSheetTestIds.TRY_AGAIN_BUTTON));
102-
fireEvent.press(
103-
getByTestId(PostTradeBottomSheetTestIds.VIEW_ACTIVITY_BUTTON),
104-
);
105180

106-
expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
181+
expect(
182+
getTrackedEvent(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_BUTTON_CLICKED),
183+
).toEqual({
184+
event: MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_BUTTON_CLICKED,
185+
properties: {
186+
status_at_click: 'failed',
187+
cta_clicked: 'try_again',
188+
time_modal_open_ms: 400,
189+
...expectedSharedProperties,
190+
},
191+
});
192+
expect(
193+
getTrackedEvent(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_DISMISSED),
194+
).toBeUndefined();
107195
expect(mockResetState).toHaveBeenCalled();
108196
expect(mockUpdateQuoteParams).toHaveBeenCalled();
109197
expect(mockDispatch).toHaveBeenCalledWith(
@@ -150,10 +238,23 @@ describe('PostTradeBottomSheet', () => {
150238

151239
const { getByTestId } = render(React.createElement(PostTradeBottomSheet));
152240

241+
mockNow = 1500;
153242
fireEvent.press(
154243
getByTestId(getPostTradeSuggestionPillTestId(suggestedToken.assetId)),
155244
);
156245

246+
expect(
247+
getTrackedEvent(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_BUTTON_CLICKED),
248+
).toMatchObject({
249+
properties: {
250+
status_at_click: 'complete',
251+
cta_clicked: 'trending_token',
252+
time_modal_open_ms: 500,
253+
token_symbol_clicked: 'UNI',
254+
token_address_clicked: '0x1111111111111111111111111111111111111111',
255+
token_clicked_is_imported: false,
256+
},
257+
});
157258
expect(mockResetState).toHaveBeenCalled();
158259
expect(mockDispatch).toHaveBeenCalledWith(
159260
expect.objectContaining({

app/components/UI/Bridge/components/PostTradeBottomSheet/index.tsx

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
22
import { useDispatch } from 'react-redux';
33
import { useNavigation } from '@react-navigation/native';
44
import type { TrendingAsset } from '@metamask/assets-controllers';
@@ -55,6 +55,13 @@ import {
5555
hidePostTradeNotificationSurface,
5656
showPostTradeNotificationSurface,
5757
} from '../../utils/postTradeNotifications';
58+
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
59+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
60+
import {
61+
getAnalyticsStatus,
62+
getPostTradeSharedAnalyticsProperties,
63+
type PostTradeAnalyticsCta,
64+
} from './PostTradeBottomSheet.analytics';
5865

5966
export const getTradeSubtitle = ({
6067
sourceAmount,
@@ -124,13 +131,21 @@ export const PostTradeBottomSheet = () => {
124131
const dispatch = useDispatch();
125132
const sheetRef = useRef<BottomSheetRef>(null);
126133
const hasRefreshedBalancesRef = useRef(false);
134+
const hasTrackedViewedRef = useRef(false);
135+
const modalOpenedAtRef = useRef(Date.now());
136+
const shouldSkipDismissedTrackingRef = useRef(false);
127137
const { styles } = useStyles(styleSheet, {});
128138
const params = useParams<PostTradeBottomSheetParams>();
129139
const updateQuoteParams = useBridgeQuoteRequest();
140+
const { trackEvent, createEventBuilder } = useAnalytics();
130141
const isBridge =
131142
params.sourceToken?.chainId &&
132143
params.destToken?.chainId &&
133144
params.sourceToken.chainId !== params.destToken.chainId;
145+
const sharedAnalyticsProperties = useMemo(
146+
() => getPostTradeSharedAnalyticsProperties(params),
147+
[params],
148+
);
134149

135150
const status = usePostTradeTxStatus({
136151
initialStatus: params.status,
@@ -139,6 +154,11 @@ export const PostTradeBottomSheet = () => {
139154
transactionHash: params.transactionHash,
140155
});
141156

157+
const getTimeModalOpenMs = useCallback(
158+
() => Date.now() - modalOpenedAtRef.current,
159+
[],
160+
);
161+
142162
useEffect(() => {
143163
showPostTradeNotificationSurface();
144164

@@ -147,6 +167,27 @@ export const PostTradeBottomSheet = () => {
147167
};
148168
}, []);
149169

170+
useEffect(() => {
171+
if (hasTrackedViewedRef.current) {
172+
return;
173+
}
174+
175+
hasTrackedViewedRef.current = true;
176+
trackEvent(
177+
createEventBuilder(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_VIEWED)
178+
.addProperties({
179+
initial_status: getAnalyticsStatus(params.status),
180+
...sharedAnalyticsProperties,
181+
})
182+
.build(),
183+
);
184+
}, [
185+
createEventBuilder,
186+
params.status,
187+
sharedAnalyticsProperties,
188+
trackEvent,
189+
]);
190+
150191
useEffect(() => {
151192
const isTerminalStatus =
152193
status === PostTradeStatus.Success || status === PostTradeStatus.Failed;
@@ -173,17 +214,75 @@ export const PostTradeBottomSheet = () => {
173214
});
174215
const titleType = isBridge ? 'bridge' : 'swap';
175216

217+
const trackButtonClicked = useCallback(
218+
(
219+
ctaClicked: PostTradeAnalyticsCta,
220+
clickedTokenProperties?: Record<string, unknown>,
221+
) => {
222+
trackEvent(
223+
createEventBuilder(
224+
MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_BUTTON_CLICKED,
225+
)
226+
.addProperties({
227+
status_at_click: getAnalyticsStatus(status),
228+
cta_clicked: ctaClicked,
229+
time_modal_open_ms: getTimeModalOpenMs(),
230+
...clickedTokenProperties,
231+
...sharedAnalyticsProperties,
232+
})
233+
.build(),
234+
);
235+
},
236+
[
237+
createEventBuilder,
238+
getTimeModalOpenMs,
239+
sharedAnalyticsProperties,
240+
status,
241+
trackEvent,
242+
],
243+
);
244+
245+
const handleDismiss = useCallback(
246+
(hasPendingAction?: boolean) => {
247+
if (shouldSkipDismissedTrackingRef.current || hasPendingAction) {
248+
shouldSkipDismissedTrackingRef.current = false;
249+
return;
250+
}
251+
252+
trackEvent(
253+
createEventBuilder(MetaMetricsEvents.SWAPBRIDGE_STATUS_MODAL_DISMISSED)
254+
.addProperties({
255+
status_at_dismissal: getAnalyticsStatus(status),
256+
time_modal_open_ms: getTimeModalOpenMs(),
257+
...sharedAnalyticsProperties,
258+
})
259+
.build(),
260+
);
261+
},
262+
[
263+
createEventBuilder,
264+
getTimeModalOpenMs,
265+
sharedAnalyticsProperties,
266+
status,
267+
trackEvent,
268+
],
269+
);
270+
176271
const handleClose = () => {
177272
sheetRef.current?.onCloseBottomSheet();
178273
};
179274

180275
const handleViewActivity = () => {
276+
trackButtonClicked('view_activity');
277+
shouldSkipDismissedTrackingRef.current = true;
181278
sheetRef.current?.onCloseBottomSheet(() => {
182279
navigation.navigate(Routes.TRANSACTIONS_VIEW);
183280
});
184281
};
185282

186283
const handleTryAgain = () => {
284+
trackButtonClicked('try_again');
285+
shouldSkipDismissedTrackingRef.current = true;
187286
if (params.sourceToken) {
188287
dispatch(setSourceToken(params.sourceToken));
189288
}
@@ -212,6 +311,13 @@ export const PostTradeBottomSheet = () => {
212311
return;
213312
}
214313

314+
trackButtonClicked('trending_token', {
315+
token_symbol_clicked: selectedDestToken.symbol,
316+
token_address_clicked: selectedDestToken.address,
317+
token_clicked_is_imported: false,
318+
});
319+
shouldSkipDismissedTrackingRef.current = true;
320+
215321
if (params.sourceToken) {
216322
dispatch(setSourceToken(params.sourceToken));
217323
}
@@ -244,7 +350,11 @@ export const PostTradeBottomSheet = () => {
244350
: undefined;
245351

246352
return (
247-
<BottomSheet ref={sheetRef} goBack={() => navigation.goBack()}>
353+
<BottomSheet
354+
ref={sheetRef}
355+
goBack={() => navigation.goBack()}
356+
onClose={handleDismiss}
357+
>
248358
<BottomSheetHeader
249359
onClose={handleClose}
250360
closeButtonProps={{ testID: PostTradeBottomSheetTestIds.CLOSE_BUTTON }}

0 commit comments

Comments
 (0)