From bdb5ec71415d76038d5fd9cd77bca5b7c81ef070 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:12:29 -0400 Subject: [PATCH 1/2] feat: add batch sell metrics --- packages/bridge-controller/CHANGELOG.md | 4 + .../src/bridge-controller.test.ts | 118 ++++++++++++++++++ .../src/bridge-controller.ts | 40 ++++-- packages/bridge-controller/src/index.ts | 5 + .../src/utils/metrics/constants.ts | 19 +++ .../src/utils/metrics/types.ts | 66 ++++++++-- 6 files changed, 233 insertions(+), 19 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index c463a6abc4..0d24121f62 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Batch Sell token and quote page analytics event types + ### Changed - Bump `@metamask/multichain-network-controller` from `^3.1.4` to `^3.2.0` ([#9264](https://github.com/MetaMask/core/pull/9264)) diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 1987be7d91..6e3abf49b1 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -13,6 +13,7 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import type { CaipAssetType } from '@metamask/utils'; import nock from 'nock'; import { flushPromises } from '../../../tests/helpers'; @@ -52,6 +53,8 @@ import { import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; import { + BatchSellMetricsEventName, + BatchSellMetricsLocation, InputAmountPreset, MetaMetricsSwapsEventSource, MetricsActionType, @@ -3032,6 +3035,121 @@ describe('BridgeController', function () { }); }); + it('should track Batch Sell token page events with default chain fallback', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + location: BatchSellMetricsLocation.TradeMenu, + feature_id: FeatureId.BATCH_SELL, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageSubmitted, + { + location: BatchSellMetricsLocation.AssetPicker, + feature_id: FeatureId.BATCH_SELL, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id: formatChainIdToCaip(ChainId.ETH), + location: BatchSellMetricsLocation.TradeMenu, + feature_id: FeatureId.BATCH_SELL, + action_type: MetricsActionType.SWAPBRIDGE_V1, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellTokenPageSubmitted, + { + chain_id: formatChainIdToCaip(ChainId.ETH), + location: BatchSellMetricsLocation.AssetPicker, + feature_id: FeatureId.BATCH_SELL, + action_type: MetricsActionType.SWAPBRIDGE_V1, + }, + ); + }); + }); + + it('should track Batch Sell quote page events with selected token metadata', async () => { + await withController(async ({ rootMessenger }) => { + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + srcChainId: ChainId.OPTIMISM, + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.BATCH_SELL, + }, + ); + jest.clearAllMocks(); + + const selectedTokenAddressList = [ + 'eip155:10/erc20:0x1111111111111111111111111111111111111111', + 'eip155:10/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const properties = { + selected_token_address_list: selectedTokenAddressList, + target_token_symbol: 'USDC', + location: BatchSellMetricsLocation.Deeplink, + slider_percentages: [25, 75], + slippage_percentages: [0.5, 1], + feature_id: FeatureId.BATCH_SELL, + }; + const expectedProperties = { + chain_id: formatChainIdToCaip(ChainId.OPTIMISM), + selected_tokens_count: selectedTokenAddressList.length, + ...properties, + action_type: MetricsActionType.SWAPBRIDGE_V1, + }; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageViewed, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotesReviewed, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageSubmitted, + properties, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellQuotePageViewed, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellQuotesReviewed, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 3, + BatchSellMetricsEventName.BatchSellQuotePageSubmitted, + expectedProperties, + ); + }); + }); + it('should track the FiatCryptoToggleClicked event', async () => { await withController(async ({ rootMessenger, controller }) => { jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index da3ec1c4b2..978f7c0b8d 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -25,7 +25,7 @@ import { ExchangeRateSourcesForLookup, selectIsAssetExchangeRateInState, } from './selectors'; -import { FeatureId, RequestStatus } from './types'; +import { ChainId, FeatureId, RequestStatus } from './types'; import type { L1GasFees, GenericQuoteRequest, @@ -63,10 +63,12 @@ import { } from './utils/fetch'; import { AbortReason, + BatchSellMetricsEventName, MetaMetricsSwapsEventSource, MetricsActionType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; +import type { BridgeControllerMetricsEventName } from './utils/metrics/constants'; import { formatProviderLabel, getAccountHardwareType, @@ -236,8 +238,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, properties: CrossChainSwapsEventProperties, @@ -279,8 +280,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, properties: CrossChainSwapsEventProperties, @@ -1165,8 +1165,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< @@ -1184,6 +1183,11 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index d66ab330dd..57fc1b82e6 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -1,13 +1,18 @@ export { BridgeController } from './bridge-controller'; export { + BatchSellMetricsEventName, UnifiedSwapBridgeEventName, + BATCH_SELL_EVENT_CATEGORY, UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY, + BatchSellMetricsLocation, InputAmountPreset, MetaMetricsSwapsEventSource, PollingStatus, } from './utils/metrics/constants'; +export type { BridgeControllerMetricsEventName } from './utils/metrics/constants'; + export type { AccountHardwareType, RequiredEventContextFromClient, diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index d44b80b524..30fcf45d27 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ export const UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY = 'Unified SwapBridge'; +export const BATCH_SELL_EVENT_CATEGORY = 'Batch Sell'; /** * These event names map to events defined in the segment-schema: https://github.com/Consensys/segment-schema/tree/main/libraries/events/metamask-cross-chain-swaps @@ -26,6 +27,18 @@ export enum UnifiedSwapBridgeEventName { PollingStatusUpdated = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Polling Status Updated`, } +export enum BatchSellMetricsEventName { + BatchSellTokenPageViewed = `${BATCH_SELL_EVENT_CATEGORY} Token Page Viewed`, + BatchSellTokenPageSubmitted = `${BATCH_SELL_EVENT_CATEGORY} Token Page Submitted`, + BatchSellQuotePageViewed = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Viewed`, + BatchSellQuotesReviewed = `${BATCH_SELL_EVENT_CATEGORY} Quotes Reviewed`, + BatchSellQuotePageSubmitted = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Submitted`, +} + +export type BridgeControllerMetricsEventName = + | UnifiedSwapBridgeEventName + | BatchSellMetricsEventName; + export enum PollingStatus { MaxPollingReached = 'max_polling_reached', InvalidTransactionHash = 'invalid_transaction_hash', @@ -57,6 +70,12 @@ export enum MetaMetricsSwapsEventSource { Unknown = 'Unknown', } +export enum BatchSellMetricsLocation { + TradeMenu = 'trade_menu', + Deeplink = 'deeplink', + AssetPicker = 'asset_picker', +} + export enum InputAmountPreset { PERCENT_25 = '25%', PERCENT_50 = '50%', diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 9e963d340e..ef01234b6a 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -9,6 +9,9 @@ import type { } from '../../types'; import type { UnifiedSwapBridgeEventName, + BatchSellMetricsEventName, + BatchSellMetricsLocation, + BridgeControllerMetricsEventName, MetaMetricsSwapsEventSource, MetricsActionType, MetricsSwapType, @@ -111,10 +114,33 @@ export type QuoteWarning = | 'quote_expired' | 'tx_alert'; +type BatchSellTokenPageEventContext = { + location: BatchSellMetricsLocation; +}; + +type BatchSellQuotePageEventContext = BatchSellTokenPageEventContext & { + selected_token_address_list: CaipAssetType[]; + target_token_symbol: string; + slider_percentages: number[]; + slippage_percentages: number[]; +}; + +type SharedEventContextFromClient = { + ab_tests?: Record; + active_ab_tests?: { key: string; value: string }[]; + feature_id: FeatureId; +}; + +type OptionalLocationContextFromClient = T extends { + location: unknown; +} + ? object + : { location?: MetaMetricsSwapsEventSource }; + /** * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. - * This is the base type without the `location` property which is added to all events - * via the RequiredEventContextFromClient mapped type. + * Most events receive an optional location via RequiredEventContextFromClient; + * Batch Sell events define their own required location enum. */ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.ButtonClicked]: Pick< @@ -287,6 +313,11 @@ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.AssetPickerOpened]: { asset_location: 'source' | 'destination'; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventContext; + [BatchSellMetricsEventName.BatchSellTokenPageSubmitted]: BatchSellTokenPageEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellQuotesReviewed]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageSubmitted]: BatchSellQuotePageEventContext; }; /** @@ -299,12 +330,9 @@ type RequiredEventContextFromClientBase = { * Both are kept for a migration window and are treated as separate payloads. */ export type RequiredEventContextFromClient = { - [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { - location?: MetaMetricsSwapsEventSource; - ab_tests?: Record; - active_ab_tests?: { key: string; value: string }[]; - feature_id: FeatureId; - }; + [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & + OptionalLocationContextFromClient & + SharedEventContextFromClient; }; /** @@ -379,6 +407,24 @@ export type EventPropertiesFromControllerState = { > & { batch_id?: string; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: { + chain_id: CaipChainId; + }; + [BatchSellMetricsEventName.BatchSellTokenPageSubmitted]: { + chain_id: CaipChainId; + }; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: { + chain_id: CaipChainId; + selected_tokens_count: number; + }; + [BatchSellMetricsEventName.BatchSellQuotesReviewed]: { + chain_id: CaipChainId; + selected_tokens_count: number; + }; + [BatchSellMetricsEventName.BatchSellQuotePageSubmitted]: { + chain_id: CaipChainId; + selected_tokens_count: number; + }; }; /** @@ -389,12 +435,12 @@ export type EventPropertiesFromControllerState = { * `ab_tests` and `active_ab_tests` intentionally coexist during migration. */ export type CrossChainSwapsEventProperties< - T extends UnifiedSwapBridgeEventName, + T extends BridgeControllerMetricsEventName, > = | { feature_id: FeatureId; action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource; + location: MetaMetricsSwapsEventSource | BatchSellMetricsLocation; ab_tests?: Record; active_ab_tests?: { key: string; value: string }[]; } From d5fd8101f7830d3cceeac2b70e39cc712b7626f6 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:09:17 -0400 Subject: [PATCH 2/2] chore: add Unknown to batch sell location and tighten up types --- .../bridge-controller/src/bridge-controller.test.ts | 9 +++++++++ packages/bridge-controller/src/bridge-controller.ts | 11 +++++++---- packages/bridge-controller/src/index.ts | 1 + .../bridge-controller/src/utils/metrics/constants.ts | 5 +++++ packages/bridge-controller/src/utils/metrics/types.ts | 6 +++--- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 6e3abf49b1..bec5760dda 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2914,6 +2914,15 @@ describe('BridgeController', function () { expect(rootMessenger.call('BridgeController:getLocation')).toBe( MetaMetricsSwapsEventSource.TokenView, ); + + rootMessenger.call( + 'BridgeController:setLocation', + BatchSellMetricsLocation.AssetPicker, + ); + + expect(rootMessenger.call('BridgeController:getLocation')).toBe( + BatchSellMetricsLocation.AssetPicker, + ); }); }); }); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 978f7c0b8d..18c5d87025 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -68,7 +68,10 @@ import { MetricsActionType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; -import type { BridgeControllerMetricsEventName } from './utils/metrics/constants'; +import type { + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, +} from './utils/metrics/constants'; import { formatProviderLabel, getAccountHardwareType, @@ -227,7 +230,7 @@ export class BridgeController extends StaticIntervalPollingController { + setLocation = (location: BridgeControllerMetricsLocation) => { this.#location = location; }; @@ -725,7 +728,7 @@ export class BridgeController extends StaticIntervalPollingController { + getLocation = (): BridgeControllerMetricsLocation => { return this.#location; }; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 57fc1b82e6..d1b61848a7 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -12,6 +12,7 @@ export { } from './utils/metrics/constants'; export type { BridgeControllerMetricsEventName } from './utils/metrics/constants'; +export type { BridgeControllerMetricsLocation } from './utils/metrics/constants'; export type { AccountHardwareType, diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 30fcf45d27..ff82fd0294 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -74,8 +74,13 @@ export enum BatchSellMetricsLocation { TradeMenu = 'trade_menu', Deeplink = 'deeplink', AssetPicker = 'asset_picker', + Unknown = 'Unknown', } +export type BridgeControllerMetricsLocation = + | MetaMetricsSwapsEventSource + | BatchSellMetricsLocation; + export enum InputAmountPreset { PERCENT_25 = '25%', PERCENT_50 = '50%', diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index ef01234b6a..80202fae7a 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -12,7 +12,7 @@ import type { BatchSellMetricsEventName, BatchSellMetricsLocation, BridgeControllerMetricsEventName, - MetaMetricsSwapsEventSource, + BridgeControllerMetricsLocation, MetricsActionType, MetricsSwapType, PollingStatus, @@ -135,7 +135,7 @@ type OptionalLocationContextFromClient = T extends { location: unknown; } ? object - : { location?: MetaMetricsSwapsEventSource }; + : { location?: BridgeControllerMetricsLocation }; /** * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. @@ -440,7 +440,7 @@ export type CrossChainSwapsEventProperties< | { feature_id: FeatureId; action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource | BatchSellMetricsLocation; + location: BridgeControllerMetricsLocation; ab_tests?: Record; active_ab_tests?: { key: string; value: string }[]; }