Skip to content

Commit b3cfe64

Browse files
fix(ramps): persist terminal analytics dedupe
Co-authored-by: Lorenzo <lorenzosantos@users.noreply.github.com>
1 parent d95691f commit b3cfe64

6 files changed

Lines changed: 137 additions & 5 deletions

File tree

app/core/Engine/controllers/ramps-controller/event-handlers/analytics.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,17 @@ describe('handleOrderStatusChangedForMetrics', () => {
929929
expect(mockTrackEvent).not.toHaveBeenCalled();
930930
});
931931

932+
it('does NOT re-emit when a previous terminal emit is rehydrated after app relaunch', () => {
933+
ReduxService.store = configureStore({
934+
terminalOrderAnalytics: { 'order-1': Date.now() },
935+
});
936+
const order = createMockOrder({ status: Status.Completed });
937+
938+
emitTerminalOrderAnalyticsFromCallback(order);
939+
940+
expect(mockTrackEvent).not.toHaveBeenCalled();
941+
});
942+
932943
it('tags a headless terminal failure as HEADLESS when a context entry exists', () => {
933944
const order = createMockOrder({
934945
status: Status.Failed,

app/core/Engine/controllers/ramps-controller/terminalOrderAnalyticsRegistry.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import {
22
getInternalOrderCode,
33
type RampsOrder,
44
} from '@metamask/ramps-controller';
5+
import ReduxService from '../../../redux';
6+
import {
7+
clearTerminalOrderAnalyticsEntries,
8+
markTerminalOrderAnalyticsEmittedEntry,
9+
} from '../../../redux/slices/terminalOrderAnalytics';
510

611
/**
712
* Terminal-order analytics dedup registry (TRAM-3691).
@@ -29,9 +34,10 @@ import {
2934
* so `getInternalOrderCode` yields an identical key for the same order. Keying
3035
* off the raw `providerOrderId` would NOT be stable across those two paths.
3136
*
32-
* In-memory, same-session only (mirrors `headlessOrderContextRegistry`). A
33-
* process relaunch clears it; that is acceptable here because a terminal order
34-
* added after relaunch is a first observation in that session and SHOULD emit.
37+
* The key is persisted in the `terminalOrderAnalytics` redux slice so app
38+
* relaunches and repeated callback returns do not turn the same order settlement
39+
* into multiple Mixpanel events. The local Set is retained as an immediate
40+
* same-tick guard in case Redux persistence is temporarily unavailable.
3541
*/
3642
const emittedTerminalOrders = new Set<string>();
3743

@@ -41,7 +47,9 @@ const emittedTerminalOrders = new Set<string>();
4147
* @param order - The order whose terminal event was just emitted.
4248
*/
4349
export function markTerminalOrderAnalyticsEmitted(order: RampsOrder): void {
44-
emittedTerminalOrders.add(getInternalOrderCode(order));
50+
const orderCode = getInternalOrderCode(order);
51+
emittedTerminalOrders.add(orderCode);
52+
ReduxService.store.dispatch(markTerminalOrderAnalyticsEmittedEntry(orderCode));
4553
}
4654

4755
/**
@@ -52,7 +60,11 @@ export function markTerminalOrderAnalyticsEmitted(order: RampsOrder): void {
5260
* @returns True when a terminal event was already emitted for the order.
5361
*/
5462
export function hasEmittedTerminalOrderAnalytics(order: RampsOrder): boolean {
55-
return emittedTerminalOrders.has(getInternalOrderCode(order));
63+
const orderCode = getInternalOrderCode(order);
64+
return (
65+
emittedTerminalOrders.has(orderCode) ||
66+
Boolean(ReduxService.store.getState().terminalOrderAnalytics[orderCode])
67+
);
5668
}
5769

5870
/**
@@ -61,4 +73,5 @@ export function hasEmittedTerminalOrderAnalytics(order: RampsOrder): boolean {
6173
*/
6274
export function __resetTerminalOrderAnalyticsRegistryForTests(): void {
6375
emittedTerminalOrders.clear();
76+
ReduxService.store.dispatch(clearTerminalOrderAnalyticsEntries());
6477
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import reducer, {
2+
TERMINAL_ORDER_ANALYTICS_TTL_MS,
3+
clearTerminalOrderAnalyticsEntries,
4+
markTerminalOrderAnalyticsEmittedEntry,
5+
type TerminalOrderAnalyticsState,
6+
} from './terminalOrderAnalytics';
7+
8+
describe('terminalOrderAnalytics slice', () => {
9+
const NOW = 1_700_000_000_000;
10+
11+
afterEach(() => {
12+
jest.restoreAllMocks();
13+
});
14+
15+
it('stores an emitted terminal order key with a timestamp', () => {
16+
jest.spyOn(Date, 'now').mockReturnValue(NOW);
17+
18+
const state = reducer(
19+
undefined,
20+
markTerminalOrderAnalyticsEmittedEntry('order-1'),
21+
);
22+
23+
expect(state).toEqual({ 'order-1': NOW });
24+
});
25+
26+
it('clears all emitted terminal order keys', () => {
27+
const initial: TerminalOrderAnalyticsState = {
28+
'order-1': NOW,
29+
'order-2': NOW,
30+
};
31+
32+
expect(reducer(initial, clearTerminalOrderAnalyticsEntries())).toEqual({});
33+
});
34+
35+
it('evicts entries older than the TTL on write', () => {
36+
const stale: TerminalOrderAnalyticsState = {
37+
'stale-order': NOW - TERMINAL_ORDER_ANALYTICS_TTL_MS - 1,
38+
'fresh-order': NOW - 1000,
39+
};
40+
jest.spyOn(Date, 'now').mockReturnValue(NOW);
41+
42+
const state = reducer(
43+
stale,
44+
markTerminalOrderAnalyticsEmittedEntry('new-order'),
45+
);
46+
47+
expect(state).toEqual({
48+
'fresh-order': NOW - 1000,
49+
'new-order': NOW,
50+
});
51+
});
52+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2+
3+
/**
4+
* Terminal analytics events represent one order reaching a terminal state
5+
* (completed/failed/cancelled). Keep a bounded persisted record so callback
6+
* re-fetches, app relaunches, and polling races cannot emit the same terminal
7+
* event repeatedly for the same order.
8+
*/
9+
export type TerminalOrderAnalyticsState = Record<string, number>;
10+
11+
/**
12+
* 90 days is long enough to cover delayed settlement and users reopening old
13+
* orders, while keeping the persisted key set bounded.
14+
*/
15+
export const TERMINAL_ORDER_ANALYTICS_TTL_MS = 90 * 24 * 60 * 60 * 1000;
16+
17+
export const initialState: TerminalOrderAnalyticsState = {};
18+
19+
const pruneExpiredEntries = (
20+
state: TerminalOrderAnalyticsState,
21+
now: number,
22+
): void => {
23+
for (const key of Object.keys(state)) {
24+
if (now - state[key] > TERMINAL_ORDER_ANALYTICS_TTL_MS) {
25+
delete state[key];
26+
}
27+
}
28+
};
29+
30+
const terminalOrderAnalyticsSlice = createSlice({
31+
name: 'terminalOrderAnalytics',
32+
initialState,
33+
reducers: {
34+
markTerminalOrderAnalyticsEmittedEntry: (
35+
state,
36+
action: PayloadAction<string>,
37+
): void => {
38+
const now = Date.now();
39+
pruneExpiredEntries(state, now);
40+
state[action.payload] = now;
41+
},
42+
clearTerminalOrderAnalyticsEntries: (): TerminalOrderAnalyticsState => ({}),
43+
},
44+
});
45+
46+
export const {
47+
markTerminalOrderAnalyticsEmittedEntry,
48+
clearTerminalOrderAnalyticsEntries,
49+
} = terminalOrderAnalyticsSlice.actions;
50+
51+
export default terminalOrderAnalyticsSlice.reducer;

app/reducers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import rewardsReducer, { RewardsState } from './rewards';
4747
import { isTestEnvironment } from '../util/test/utils';
4848
import attributionReducer from '../core/redux/slices/attribution';
4949
import headlessOrderContextsReducer from '../core/redux/slices/headlessOrderContexts';
50+
import terminalOrderAnalyticsReducer from '../core/redux/slices/terminalOrderAnalytics';
5051

5152
/**
5253
* Infer state from a reducer
@@ -138,6 +139,7 @@ export interface RootState {
138139
networkConnectionBanner: NetworkConnectionBannerState;
139140
attribution: StateFromReducer<typeof attributionReducer>;
140141
headlessOrderContexts: StateFromReducer<typeof headlessOrderContextsReducer>;
142+
terminalOrderAnalytics: StateFromReducer<typeof terminalOrderAnalyticsReducer>;
141143
}
142144

143145
const baseReducers = {
@@ -196,6 +198,7 @@ const rootReducer = combineReducers<RootState, any>({
196198
...baseReducers,
197199
attribution: attributionReducer,
198200
headlessOrderContexts: headlessOrderContextsReducer,
201+
terminalOrderAnalytics: terminalOrderAnalyticsReducer,
199202
});
200203

201204
export default rootReducer;

app/util/test/initial-root-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { initialState as initialBridgeState } from '../../core/redux/slices/brid
99
import { initialState as initialQrKeyringScannerState } from '../../core/redux/slices/qrKeyringScanner';
1010
import { initialState as initialCardState } from '../../core/redux/slices/card';
1111
import { initialState as initialMoneyBalanceState } from '../../core/redux/slices/moneyBalance';
12+
import { initialState as initialTerminalOrderAnalyticsState } from '../../core/redux/slices/terminalOrderAnalytics';
1213
import initialBackgroundState from './initial-background-state.json';
1314
import { userInitialState } from '../../reducers/user';
1415
import { initialNavigationState } from '../../reducers/navigation';
@@ -82,6 +83,7 @@ const initialRootState: RootState = {
8283
attribution: null,
8384
},
8485
headlessOrderContexts: {},
86+
terminalOrderAnalytics: initialTerminalOrderAnalyticsState,
8587
};
8688

8789
if (isTestEnvironment) {

0 commit comments

Comments
 (0)