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..928704e439 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, @@ -2911,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, + ); }); }); }); @@ -3032,6 +3044,141 @@ describe('BridgeController', function () { }); }); + it('should track Batch Sell token page events with default chain fallback', async () => { + await withController(async ({ rootMessenger }) => { + const sourceTokenAddresses = [ + 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const sourceTokenSymbols = ['LINK', 'UNI']; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + location: BatchSellMetricsLocation.AssetPicker, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id_source: formatChainIdToCaip(ChainId.ETH), + chain_id_destination: null, + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + chain_id_source: formatChainIdToCaip(ChainId.ETH), + chain_id_destination: null, + location: BatchSellMetricsLocation.AssetPicker, + source_token_count: sourceTokenAddresses.length, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + }); + }); + + 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, + destChainId: 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 sourceTokenAddresses = [ + 'eip155:10/erc20:0x1111111111111111111111111111111111111111', + 'eip155:10/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const destinationTokenAddress = + 'eip155:10/erc20:0x3333333333333333333333333333333333333333' satisfies CaipAssetType; + const properties = { + location: BatchSellMetricsLocation.Deeplink, + source_token_symbols: ['WETH', 'OP'], + source_token_addresses: sourceTokenAddresses, + destination_token_symbol: 'USDC', + destination_token_address: destinationTokenAddress, + usd_amount_source_tokens: [10, 20], + usd_amount_source_total: 30, + source_token_slippages: [0.5, 1], + }; + const expectedProperties = { + chain_id_source: formatChainIdToCaip(ChainId.OPTIMISM), + chain_id_destination: formatChainIdToCaip(ChainId.OPTIMISM), + source_token_count: sourceTokenAddresses.length, + ...properties, + }; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageViewed, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...properties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellQuotePageViewed, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 3, + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...expectedProperties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + }); + }); + 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..65e93ed15f 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,15 @@ import { } from './utils/fetch'; import { AbortReason, + BatchSellMetricsEventName, MetaMetricsSwapsEventSource, MetricsActionType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; +import type { + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, +} from './utils/metrics/constants'; import { formatProviderLabel, getAccountHardwareType, @@ -225,7 +230,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, properties: CrossChainSwapsEventProperties, @@ -279,8 +283,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, properties: CrossChainSwapsEventProperties, @@ -716,7 +719,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; }; @@ -1165,8 +1168,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< @@ -1174,7 +1176,7 @@ export class BridgeController extends StaticIntervalPollingController[EventName], quoteRequestIndex: number = 0, - ): CrossChainSwapsEventProperties => { + ) => { const clientProps = propertiesFromClient as Record; const baseProperties = { ...propertiesFromClient, @@ -1184,6 +1186,16 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< @@ -1366,7 +1432,10 @@ export class BridgeController extends StaticIntervalPollingController, + ); } catch (error) { console.error( `Error tracking cross-chain swaps MetaMetrics event ${eventName}`, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index d66ab330dd..d1b61848a7 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -1,13 +1,19 @@ 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 { BridgeControllerMetricsLocation } 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..af28b18fa1 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`, + BatchSellTokenPageContinueClicked = `${BATCH_SELL_EVENT_CATEGORY} Token Page Continue Clicked`, + BatchSellQuotePageViewed = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Viewed`, + BatchSellQuotePageReviewClicked = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Review Clicked`, + BatchSellReviewModalSubmitted = `${BATCH_SELL_EVENT_CATEGORY} Review Modal Submitted`, +} + +export type BridgeControllerMetricsEventName = + | UnifiedSwapBridgeEventName + | BatchSellMetricsEventName; + export enum PollingStatus { MaxPollingReached = 'max_polling_reached', InvalidTransactionHash = 'invalid_transaction_hash', @@ -57,6 +70,17 @@ export enum MetaMetricsSwapsEventSource { Unknown = 'Unknown', } +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 9e963d340e..711b93fe2a 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -9,7 +9,10 @@ import type { } from '../../types'; import type { UnifiedSwapBridgeEventName, - MetaMetricsSwapsEventSource, + BatchSellMetricsEventName, + BatchSellMetricsLocation, + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, MetricsActionType, MetricsSwapType, PollingStatus, @@ -111,10 +114,67 @@ export type QuoteWarning = | 'quote_expired' | 'tx_alert'; +type BatchSellTokenPageEventContext = { + location: BatchSellMetricsLocation; +}; + +type BatchSellSourceTokenEventContext = BatchSellTokenPageEventContext & { + source_token_symbols: string[]; + source_token_addresses: CaipAssetType[]; +}; + +type BatchSellQuotePageEventContext = BatchSellSourceTokenEventContext & { + destination_token_symbol: string; + destination_token_address: CaipAssetType; + usd_amount_source_tokens: number[]; + usd_amount_source_total: number; + source_token_slippages: number[]; +}; + +type BatchSellReviewModalSubmittedEventContext = + BatchSellQuotePageEventContext & + Pick; + +type BatchSellChainProperties = { + chain_id_source: CaipChainId; + chain_id_destination: CaipChainId | null; +}; + +type BatchSellTokenPageEventProperties = BatchSellChainProperties & + BatchSellTokenPageEventContext; + +type BatchSellSourceTokenEventProperties = BatchSellChainProperties & + BatchSellSourceTokenEventContext & { + source_token_count: number; + }; + +type BatchSellQuotePageEventProperties = BatchSellChainProperties & + BatchSellQuotePageEventContext & { + source_token_count: number; + }; + +type BatchSellReviewModalSubmittedEventProperties = + BatchSellChainProperties & + BatchSellReviewModalSubmittedEventContext & { + source_token_count: number; + }; + +type SharedEventContextFromClient = { + ab_tests?: Record; + active_ab_tests?: { key: string; value: string }[]; + feature_id: FeatureId; +}; + +type OptionalLocationContextFromClient = T extends { + location: unknown; +} + ? object + : { location?: BridgeControllerMetricsLocation }; + /** * 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 +347,11 @@ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.AssetPickerOpened]: { asset_location: 'source' | 'destination'; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventContext; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventContext; }; /** @@ -299,12 +364,13 @@ 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]: K extends BatchSellMetricsEventName + ? RequiredEventContextFromClientBase[K] + : RequiredEventContextFromClientBase[K] & + OptionalLocationContextFromClient< + RequiredEventContextFromClientBase[K] + > & + SharedEventContextFromClient; }; /** @@ -379,24 +445,35 @@ export type EventPropertiesFromControllerState = { > & { batch_id?: string; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventProperties; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventProperties; }; -/** - * trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client - * and properties from the bridge controller - * - * `ab_tests` will be deprecated in favor of `active_ab_tests` in the future. - * `ab_tests` and `active_ab_tests` intentionally coexist during migration. - */ -export type CrossChainSwapsEventProperties< - T extends UnifiedSwapBridgeEventName, +type SharedCrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, > = | { feature_id: FeatureId; action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource; + location: BridgeControllerMetricsLocation; ab_tests?: Record; active_ab_tests?: { key: string; value: string }[]; } | Pick[T] | Pick[T]; + +/** + * trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client + * and properties from the bridge controller + * + * `ab_tests` will be deprecated in favor of `active_ab_tests` in the future. + * `ab_tests` and `active_ab_tests` intentionally coexist during migration. + */ +export type CrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, +> = T extends BatchSellMetricsEventName + ? Pick[T] + : SharedCrossChainSwapsEventProperties;