From ab7410db351c8580d52ba210999a6c0f5a7c973f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 08:54:27 +0200 Subject: [PATCH 1/6] feat(perps-controller): consolidate analytics contract and transaction pipeline Add the consolidated Perps analytics contract (five new PerpsAnalyticsEvent names, ticket-sourced PERPS_EVENT_PROPERTY keys and PERPS_EVENT_VALUE entries, PerpsAttributionContext API for UTM propagation) and the transaction-pipeline behavior in TradingService: status=submitted events before provider calls, metamask_fee on flip success, leverage on close, hl_fee_rate on trade/close (omitted when absent), bulk_action_id for batch close/cancel, and discovery attribution propagation. Additive only; no events renamed or removed. Ref: TAT-3463 --- packages/perps-controller/CHANGELOG.md | 13 + .../PerpsController-method-action-types.ts | 29 +- .../perps-controller/src/PerpsController.ts | 71 ++++ .../src/constants/eventNames.ts | 78 +++++ packages/perps-controller/src/index.ts | 4 + .../src/services/TradingService.ts | 153 ++++++++- packages/perps-controller/src/types/index.ts | 46 ++- .../tests/src/PerpsController.state.test.ts | 58 ++++ .../tests/src/constants/eventNames.test.ts | 141 ++++++++ .../tests/src/services/TradingService.test.ts | 316 ++++++++++++++++++ 10 files changed, 904 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8db458d2535..acf63b7f95f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Consolidate the Perps analytics contract so clients import a single source of truth from `@metamask/perps-controller` (TAT-3463) + - Add five new `PerpsAnalyticsEvent` members: `TransactionConsidered` (`Perp Transaction Considered`), `TradeQuoteReceived` (`Perp Trade Quote Received`), `SearchQuery` (`Perp Search Query`), `SearchResultTapped` (`Perp Search Result Tapped`), `SearchAbandoned` (`Perp Search Abandoned`) + - Add new `PERPS_EVENT_PROPERTY` keys: `entry_point`, `discovery_source`, `perp_discovery_source`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `watchlisted`, `hl_fee_rate`, `bulk_action_id`, `environment_type`, `order_context`, `order_size_percent`, `limit_price_input_type`, `limit_price_input_preset`, `order_has_tp`, `order_has_sl`, `quote_latency_ms`, `error_reason`, `saved_order`, `default_payment_token`, `default_size_amount`, `default_leverage`, `default_auto_close`, `order_execution_latency_ms`, `screen_context`, `from_token`, `from_chain`, `to_token`, `to_chain`, `search_query`, `results_count`, `result_rank`, `mode`, `current_token`, `sort_field`, `sort_direction`, `filter_category`, `time_on_screen_ms` + - Add new `PERPS_EVENT_VALUE` entries: `INTERACTION_TYPE.{SORT_APPLIED, FILTER_APPLIED, SEARCH_RESULT_TAPPED, SEARCH_CHIP_TAPPED, SEARCH_SIGNAL_TILE_TAPPED, PAYMENT_TOKEN_SELECTOR_DISMISSED}`, `ACTION.ABANDON_ORDER`, `BUTTON_CLICKED.{PLACE_ORDER, CLOSE, REDUCE_EXPOSURE}`, `SCREEN_TYPE.{SEARCH_RESULTS_SHOWN, SEARCH_NO_RESULTS}` + - Add `PerpsAttributionContext` type and `setAttributionContext` / `getAttributionContext` / `clearAttributionContext` / `mergeAttributionContext` on `PerpsController` (with matching messenger actions) for transient UTM attribution propagation + - Extend `TrackingData` with `entryPoint`, `discoverySource`, `perpDiscoverySource`, `hlFeeRate`; extend `TPSLTrackingData` with `entryPoint`, `discoverySource`, `perpDiscoverySource`; add optional `trackingData` to `CancelOrderParams` - Add Perps Advanced Chart analytics constants to `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` so mobile can import chart instrumentation keys from `@metamask/perps-controller` instead of maintaining a local mirror ([#9221](https://github.com/MetaMask/core/pull/9221)) - New `PERPS_EVENT_PROPERTY` keys: `CHART_LIBRARY`, `ASSET_TYPE` - New `PERPS_EVENT_VALUE.CHART_LIBRARY` group: `lightweight`, `advanced` @@ -18,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Consolidate the Perps transaction analytics pipeline in `TradingService` (TAT-3463) + - Emit a `status: 'submitted'` event before the provider round-trip for trade (`placeOrder`), close (`closePosition`), cancel (`cancelOrder`) and risk-management (`updatePositionTPSL`) operations + - Populate `metamask_fee` on successful `flipPosition` trades from `trackingData` + - Add `leverage` to `Perp Position Close Transaction` event properties + - Add `hl_fee_rate` to trade and close events when present in `trackingData`; omit it entirely when unavailable + - Generate a `bulk_action_id` UUID for `closePositions` / `cancelOrders` and attach it to each per-item event and the batch summary event + - Propagate `entry_point`, `discovery_source`, `perp_discovery_source` from `trackingData` onto trade/close/cancel/risk events; the legacy `source` field on `TPSLTrackingData` is now deprecated - On `subscribeToPrices` calls with `includeMarketData: true` (focused detail/ticket screens), the `price` field in each `PriceUpdate` is now driven by the per-symbol `activeAssetCtx` WebSocket stream (`midPx`, falling back to `markPx`) rather than the main-DEX `allMids` snapshot, which Hyperliquid throttles to a ~5 s push cadence ([#9160](https://github.com/MetaMask/core/pull/9160)) - Price source selection is **per-subscriber**: focused (`includeMarketData: true`) callbacks receive the fast-stream price; list/overview (`includeMarketData: false`) callbacks always receive the raw `allMids` baseline, even when both subscriber types share the same symbol. - The fast-stream price is preferred only while it is fresh (within a 10 s staleness window); `allMids` takes back over automatically once the `activeAssetCtx` stream goes quiet. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index e1880e9f249..c1435422599 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -1026,6 +1026,30 @@ export type PerpsControllerIsCurrentlyReinitializingAction = { handler: PerpsController['isCurrentlyReinitializing']; }; +/** + * Set the transient UTM / discovery attribution context (TAT-3133, TAT-3140). + */ +export type PerpsControllerSetAttributionContextAction = { + type: `PerpsController:setAttributionContext`; + handler: PerpsController['setAttributionContext']; +}; + +/** + * Get the current UTM / discovery attribution context (TAT-3133, TAT-3140). + */ +export type PerpsControllerGetAttributionContextAction = { + type: `PerpsController:getAttributionContext`; + handler: PerpsController['getAttributionContext']; +}; + +/** + * Clear the stored UTM / discovery attribution context (TAT-3133, TAT-3140). + */ +export type PerpsControllerClearAttributionContextAction = { + type: `PerpsController:clearAttributionContext`; + handler: PerpsController['clearAttributionContext']; +}; + /** * Union of all PerpsController action types. */ @@ -1116,4 +1140,7 @@ export type PerpsControllerMethodActions = | PerpsControllerToggleWatchlistMarketAction | PerpsControllerIsWatchlistMarketAction | PerpsControllerGetWatchlistMarketsAction - | PerpsControllerIsCurrentlyReinitializingAction; + | PerpsControllerIsCurrentlyReinitializingAction + | PerpsControllerSetAttributionContextAction + | PerpsControllerGetAttributionContextAction + | PerpsControllerClearAttributionContextAction; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 150feec05ee..165f94edd6a 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -111,6 +111,8 @@ import type { PerpsPlatformDependencies, PerpsLogger, PerpsActiveProviderMode, + PerpsAnalyticsProperties, + PerpsAttributionContext, PerpsProviderType, PerpsSelectedPaymentToken, PerpsRemoteFeatureFlagState, @@ -734,6 +736,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'calculateMaintenanceMargin', 'cancelOrder', 'cancelOrders', + 'clearAttributionContext', 'clearDepositResult', 'clearPendingTradeConfiguration', 'clearPendingTransactionRequests', @@ -750,6 +753,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getAccountState', 'getActiveProvider', 'getActiveProviderOrNull', + 'getAttributionContext', 'getAvailableDexs', 'getBlockExplorerUrl', 'getCachedMarketDataForActiveProvider', @@ -790,6 +794,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'savePendingTradeConfiguration', 'saveTradeConfiguration', + 'setAttributionContext', 'setLiveDataConfig', 'setSelectedPaymentToken', 'startEligibilityMonitoring', @@ -869,6 +874,13 @@ export class PerpsController extends BaseController< // through to each provider, which applies its own default when this is undefined. readonly #priceDeviationLimit?: number; + /** + * Transient UTM / discovery attribution context (TAT-3133, TAT-3140). + * Held in-memory only (never persisted in PerpsControllerState) and merged + * into analytics event properties via {@link mergeAttributionContext}. + */ + #attributionContext: PerpsAttributionContext = {}; + /** * Check if MYX provider is enabled via feature flag * Uses same pattern as other feature flags in FeatureFlagConfigurationService @@ -3852,6 +3864,65 @@ export class PerpsController extends BaseController< } } + /** + * Set the transient UTM / discovery attribution context (TAT-3133, TAT-3140). + * Replaces any previously set context. Held in-memory only — not persisted. + * + * @param context - The attribution context (UTM fields) to store. + */ + setAttributionContext(context: PerpsAttributionContext): void { + this.#attributionContext = { ...context }; + } + + /** + * Get a copy of the current attribution context (TAT-3133, TAT-3140). + * + * @returns A shallow copy of the stored attribution context. + */ + getAttributionContext(): PerpsAttributionContext { + return { ...this.#attributionContext }; + } + + /** + * Clear the stored attribution context (TAT-3133, TAT-3140). + */ + clearAttributionContext(): void { + this.#attributionContext = {}; + } + + /** + * Merge the stored UTM attribution context into a set of analytics event + * properties (TAT-3133, TAT-3140). Only defined UTM fields are added, mapped + * to their canonical PERPS_EVENT_PROPERTY keys. Provided properties take + * precedence and are never overwritten. + * + * @param properties - Base event properties to merge attribution into. + * @returns A new properties object including any defined UTM keys. + */ + mergeAttributionContext( + properties: PerpsAnalyticsProperties = {}, + ): PerpsAnalyticsProperties { + const utm: PerpsAnalyticsProperties = {}; + const context = this.#attributionContext; + if (context.utmSource !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_SOURCE] = context.utmSource; + } + if (context.utmMedium !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_MEDIUM] = context.utmMedium; + } + if (context.utmCampaign !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_CAMPAIGN] = context.utmCampaign; + } + if (context.utmContent !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_CONTENT] = context.utmContent; + } + if (context.utmTerm !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_TERM] = context.utmTerm; + } + // Provided properties win over attribution context. + return { ...utm, ...properties }; + } + /** * Toggle between testnet and mainnet * diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index e6afd62d8e4..b5a0eb497d1 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -178,6 +178,66 @@ export const PERPS_EVENT_PROPERTY = { // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) ABSTRACTION_MODE: 'abstraction_mode', PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', + + // Entry point / discovery attribution (TAT-3080) + ENTRY_POINT: 'entry_point', + DISCOVERY_SOURCE: 'discovery_source', + PERP_DISCOVERY_SOURCE: 'perp_discovery_source', + + // UTM attribution context (TAT-3133, TAT-3140) + UTM_SOURCE: 'utm_source', + UTM_MEDIUM: 'utm_medium', + UTM_CAMPAIGN: 'utm_campaign', + UTM_CONTENT: 'utm_content', + UTM_TERM: 'utm_term', + + // Watchlist membership at event time (TAT-3148) + WATCHLISTED: 'watchlisted', + + // HyperLiquid protocol fee rate on trade + close (TAT-3149) + HL_FEE_RATE: 'hl_fee_rate', + + // Bulk action correlation id for batch close/cancel (TAT-3150) + BULK_ACTION_ID: 'bulk_action_id', + + // Client environment (Extension supplies value) (TAT-3335) + ENVIRONMENT_TYPE: 'environment_type', + + // Order funnel / consideration + quote properties (TAT-3084) + ORDER_CONTEXT: 'order_context', + ORDER_SIZE_PERCENT: 'order_size_percent', + LIMIT_PRICE_INPUT_TYPE: 'limit_price_input_type', + LIMIT_PRICE_INPUT_PRESET: 'limit_price_input_preset', + ORDER_HAS_TP: 'order_has_tp', + ORDER_HAS_SL: 'order_has_sl', + QUOTE_LATENCY_MS: 'quote_latency_ms', + ERROR_REASON: 'error_reason', + SAVED_ORDER: 'saved_order', + DEFAULT_PAYMENT_TOKEN: 'default_payment_token', + DEFAULT_SIZE_AMOUNT: 'default_size_amount', + DEFAULT_LEVERAGE: 'default_leverage', + DEFAULT_AUTO_CLOSE: 'default_auto_close', + ORDER_EXECUTION_LATENCY_MS: 'order_execution_latency_ms', + SCREEN_CONTEXT: 'screen_context', + FROM_TOKEN: 'from_token', + FROM_CHAIN: 'from_chain', + TO_TOKEN: 'to_token', + TO_CHAIN: 'to_chain', + + // Search / discovery query properties (TAT-3144, TAT-3202, TAT-3151) + SEARCH_QUERY: 'search_query', + RESULTS_COUNT: 'results_count', + RESULT_RANK: 'result_rank', + MODE: 'mode', + CURRENT_TOKEN: 'current_token', + + // Sort / filter properties (TAT-3142) + SORT_FIELD: 'sort_field', + SORT_DIRECTION: 'sort_direction', + FILTER_CATEGORY: 'filter_category', + + // Time-on-screen for abandon tracking (TAT-3136) + TIME_ON_SCREEN_MS: 'time_on_screen_ms', } as const; /** @@ -396,6 +456,15 @@ export const PERPS_EVENT_VALUE = { SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', // Discovery analytics MARKET_LIST_FILTER: 'market_list_filter', + // Sort / filter interactions (TAT-3142) + SORT_APPLIED: 'sort_applied', + FILTER_APPLIED: 'filter_applied', + // Search interactions (TAT-3144, TAT-3202) + SEARCH_RESULT_TAPPED: 'search_result_tapped', + SEARCH_CHIP_TAPPED: 'search_chip_tapped', + SEARCH_SIGNAL_TILE_TAPPED: 'search_signal_tile_tapped', + // Pay-with token selector dismissed (TAT-3151) + PAYMENT_TOKEN_SELECTOR_DISMISSED: 'payment_token_selector_dismissed', }, MAX_SLIPPAGE_SOURCE: { DEFAULT: 'default', @@ -480,6 +549,9 @@ export const PERPS_EVENT_VALUE = { COMPLIANCE_BLOCK_NOTIF: 'compliance_block_notif', // Deposit + order (pay-with token) cancel toast CANCEL_TRADE_WITH_TOKEN_TOAST: 'cancel_trade_with_token_toast', + // Search result screen states (TAT-3144) + SEARCH_RESULTS_SHOWN: 'search_results_shown', + SEARCH_NO_RESULTS: 'search_no_results', }, SETTING_TYPE: { LEVERAGE: 'leverage', @@ -512,6 +584,8 @@ export const PERPS_EVENT_VALUE = { // Flip position actions with direction specificity FLIP_LONG_TO_SHORT: 'flip_long_to_short', FLIP_SHORT_TO_LONG: 'flip_short_to_long', + // Order funnel abandonment (TAT-3136) + ABANDON_ORDER: 'abandon_order', }, // Risk management sources RISK_MANAGEMENT_SOURCE: { @@ -555,6 +629,10 @@ export const PERPS_EVENT_VALUE = { WATCHLIST: 'watchlist', TOP_MOVERS: 'top_movers', WHATS_HAPPENING: 'whats_happening', + // Order + position management CTAs (TAT-3135, TAT-3141) + PLACE_ORDER: 'place_order', + CLOSE: 'close', + REDUCE_EXPOSURE: 'reduce_exposure', }, BUTTON_LOCATION: { PERPS_HOME: 'perps_home', diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index c88128742d1..47efd4946c7 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -53,6 +53,7 @@ export type { PerpsControllerClearWithdrawResultAction, PerpsControllerClosePositionAction, PerpsControllerClosePositionsAction, + PerpsControllerClearAttributionContextAction, PerpsControllerCompleteWithdrawalFromHistoryAction, PerpsControllerDepositWithConfirmationAction, PerpsControllerDepositWithOrderAction, @@ -63,6 +64,7 @@ export type { PerpsControllerGetAccountStateAction, PerpsControllerGetActiveProviderAction, PerpsControllerGetActiveProviderOrNullAction, + PerpsControllerGetAttributionContextAction, PerpsControllerGetAvailableDexsAction, PerpsControllerGetBlockExplorerUrlAction, PerpsControllerGetCachedMarketDataForActiveProviderAction, @@ -101,6 +103,7 @@ export type { PerpsControllerSaveOrderBookGroupingAction, PerpsControllerSavePendingTradeConfigurationAction, PerpsControllerSaveTradeConfigurationAction, + PerpsControllerSetAttributionContextAction, PerpsControllerSetLiveDataConfigAction, PerpsControllerSetSelectedPaymentTokenAction, PerpsControllerStartEligibilityMonitoringAction, @@ -233,6 +236,7 @@ export type { PerpsTraceName, PerpsTraceValue, PerpsAnalyticsProperties, + PerpsAttributionContext, PerpsMetrics, PerpsDebugLogger, PerpsStreamManager, diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index d0b2669626f..5e7330509dd 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -42,6 +42,16 @@ export type TradingServiceControllerDeps = { rewardsIntegrationService: RewardsIntegrationService; }; +/** + * Subset of tracking data carrying discovery attribution + hl_fee_rate that is + * shared across trade/close/cancel/risk events (TAT-3080, TAT-3149). Both + * {@link TrackingData} and {@link TPSLTrackingData} satisfy this shape. + */ +type AttributionTrackingData = Pick< + TrackingData, + 'entryPoint' | 'discoverySource' | 'perpDiscoverySource' | 'hlFeeRate' +>; + /** * TradingService * @@ -103,6 +113,52 @@ export class TradingService { }; } + /** + * Build discovery/attribution properties shared across trade/close/cancel/risk + * events (TAT-3080, TAT-3149). Each property is only included when present so + * that, in particular, hl_fee_rate is omitted entirely when unavailable. + * + * @param trackingData - Optional tracking data carried on the operation params. + * @returns The attribution properties to merge into an analytics event. + */ + #buildAttributionProperties( + trackingData?: AttributionTrackingData, + ): PerpsAnalyticsProperties { + const properties: PerpsAnalyticsProperties = {}; + if (trackingData?.entryPoint !== undefined) { + properties[PERPS_EVENT_PROPERTY.ENTRY_POINT] = trackingData.entryPoint; + } + if (trackingData?.discoverySource !== undefined) { + properties[PERPS_EVENT_PROPERTY.DISCOVERY_SOURCE] = + trackingData.discoverySource; + } + if (trackingData?.perpDiscoverySource !== undefined) { + properties[PERPS_EVENT_PROPERTY.PERP_DISCOVERY_SOURCE] = + trackingData.perpDiscoverySource; + } + if (trackingData?.hlFeeRate !== undefined) { + properties[PERPS_EVENT_PROPERTY.HL_FEE_RATE] = trackingData.hlFeeRate; + } + return properties; + } + + /** + * Emit a transaction event with status=submitted before the provider round-trip + * (TAT-3134). Fired for trade, close, cancel and risk-management operations. + * + * @param event - The analytics event name to emit. + * @param properties - Additional event properties (asset, attribution, etc.). + */ + #trackSubmitted( + event: PerpsAnalyticsEvent, + properties: PerpsAnalyticsProperties, + ): void { + this.#deps.metrics.trackPerpsEvent(event, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUBMITTED, + ...properties, + }); + } + /** * Track order result analytics event (success or failure) * @@ -243,6 +299,12 @@ export class TradingService { properties[PERPS_EVENT_PROPERTY.AB_TESTS] = params.trackingData.abTests; } + // Propagate discovery attribution + hl_fee_rate (TAT-3080, TAT-3149) + Object.assign( + properties, + this.#buildAttributionProperties(params.trackingData), + ); + this.#deps.metrics.trackPerpsEvent( PerpsAnalyticsEvent.TradeTransaction, properties, @@ -451,6 +513,19 @@ export class TradingService { }, ); + // Emit submitted event before the provider round-trip (TAT-3134) + this.#trackSubmitted(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat( + String(params.leverage ?? 1), + ), + ...this.#buildAttributionProperties(params.trackingData), + }); + // Observational threshold: when the provider round-trip exceeds // PlaceOrderTimeoutMs we tag the trace and emit a breadcrumb, but we // intentionally do NOT cancel the in-flight order. Cancelling client-side @@ -761,6 +836,12 @@ export class TradingService { ...(params.trackingData?.vipDiscount !== undefined && { [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: params.trackingData.vipDiscount, }), + // Leverage on close events (TAT-3147) + ...(position.leverage?.value !== undefined && { + [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage.value, + }), + // Discovery attribution + hl_fee_rate (TAT-3080, TAT-3149) + ...this.#buildAttributionProperties(params.trackingData), }; // Calculate and add order value in USD (size * price) @@ -803,6 +884,7 @@ export class TradingService { * @param options.params - The operation parameters. * @param options.context - The service context for dependencies. * @param options.duration - Optional time duration. + * @param options.bulkActionId - Optional batch correlation id (TAT-3150). */ #trackPositionCloseResult(options: { position: Position | undefined; @@ -811,13 +893,19 @@ export class TradingService { params: ClosePositionParams; context: ServiceContext; duration: number; + bulkActionId?: string; }): void { - const { position, result, error, params, duration } = options; + const { position, result, error, params, duration, bulkActionId } = options; if (!position) { return; } + // Bulk action correlation id for batch close events (TAT-3150) + const bulkActionProps: PerpsAnalyticsProperties = bulkActionId + ? { [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId } + : {}; + const metrics = result ? this.#calculateCloseMetrics(position, params, result) : { @@ -856,6 +944,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: metrics.requestedSize - metrics.filledSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...bulkActionProps, }, ); } @@ -883,6 +972,7 @@ export class TradingService { { ...eventProperties, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...bulkActionProps, }, ); } @@ -1133,20 +1223,30 @@ export class TradingService { * @param options.provider - The perps provider instance. * @param options.params - The operation parameters. * @param options.context - The service context for dependencies. + * @param options.bulkActionId - Optional batch correlation id (TAT-3150). * @returns The result of the operation. */ async cancelOrder(options: { provider: PerpsProvider; params: CancelOrderParams; context: ServiceContext; + bulkActionId?: string; }): Promise { - const { provider, params, context } = options; + const { provider, params, context, bulkActionId } = options; const traceId = uuidv4(); const startTime = this.#deps.performance.now(); let traceData: | { success: boolean; error?: string; orderId?: string } | undefined; + // Shared attribution + bulk correlation props (TAT-3080, TAT-3150) + const cancelExtraProps: PerpsAnalyticsProperties = { + ...this.#buildAttributionProperties(params.trackingData), + ...(bulkActionId && { + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + }), + }; + try { // Start trace for the entire operation this.#deps.tracer.trace({ @@ -1163,6 +1263,12 @@ export class TradingService { }, }); + // Emit submitted event before the provider round-trip (TAT-3134) + this.#trackSubmitted(PerpsAnalyticsEvent.OrderCancelTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + ...cancelExtraProps, + }); + // Execute order cancellation const result = await provider.cancelOrder(params); const completionDuration = this.#deps.performance.now() - startTime; @@ -1182,6 +1288,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + ...cancelExtraProps, }, ); @@ -1196,6 +1303,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: result.error ?? 'Unknown error', + ...cancelExtraProps, }, ); @@ -1224,6 +1332,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: error instanceof Error ? error.message : 'Unknown error', + ...cancelExtraProps, }, ); @@ -1268,6 +1377,8 @@ export class TradingService { }): Promise { const { provider, params, context, withStreamPause } = options; const traceId = uuidv4(); + // Correlation id linking every per-item event to the batch summary (TAT-3150) + const bulkActionId = uuidv4(); const startTime = this.#deps.performance.now(); let operationResult: CancelOrdersResult | null = null; let operationError: Error | null = null; @@ -1346,6 +1457,7 @@ export class TradingService { provider, params: { symbol: order.symbol, orderId: order.orderId }, context, + bulkActionId, }), ), ); @@ -1427,6 +1539,7 @@ export class TradingService { ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, }; if (operationError) { batchCancelProps[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = @@ -1453,6 +1566,7 @@ export class TradingService { * @param options.params - The operation parameters. * @param options.context - The service context for dependencies. * @param options.reportOrderToDataLake - The report order to data lake value. + * @param options.bulkActionId - Optional batch correlation id (TAT-3150). * @returns The result of the operation. */ async closePosition(options: { @@ -1463,8 +1577,10 @@ export class TradingService { action: 'open' | 'close'; symbol: string; }) => Promise<{ success: boolean; error?: string }>; + bulkActionId?: string; }): Promise { - const { provider, params, context, reportOrderToDataLake } = options; + const { provider, params, context, reportOrderToDataLake, bulkActionId } = + options; const traceId = uuidv4(); const startTime = this.#deps.performance.now(); let position: Position | undefined; @@ -1492,6 +1608,17 @@ export class TradingService { context, }); + // Emit submitted event before the provider round-trip (TAT-3134) + this.#trackSubmitted(PerpsAnalyticsEvent.PositionCloseTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: + params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + ...this.#buildAttributionProperties(params.trackingData), + ...(bulkActionId && { + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + }), + }); + // Calculate fee discount with measurement const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); @@ -1543,6 +1670,7 @@ export class TradingService { params, context, duration: completionDuration, + bulkActionId, }); return result; @@ -1562,6 +1690,7 @@ export class TradingService { params, context, duration: completionDuration, + bulkActionId, }); this.#deps.logger.error( @@ -1610,6 +1739,8 @@ export class TradingService { }): Promise { const { provider, params, context } = options; const traceId = uuidv4(); + // Correlation id linking every per-item event to the batch summary (TAT-3150) + const bulkActionId = uuidv4(); const startTime = this.#deps.performance.now(); let operationResult: ClosePositionsResult | null = null; let operationError: Error | null = null; @@ -1684,6 +1815,7 @@ export class TradingService { params: { symbol: position.symbol }, context, reportOrderToDataLake: () => Promise.resolve({ success: true }), // No-op for batch fallback + bulkActionId, }), ), ); @@ -1767,6 +1899,7 @@ export class TradingService { ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, }; if (operationError) { batchCloseProps[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = @@ -1838,6 +1971,13 @@ export class TradingService { }, }); + // Emit submitted event before the provider round-trip (TAT-3134) + this.#trackSubmitted(PerpsAnalyticsEvent.RiskManagement, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.SOURCE]: source, + ...this.#buildAttributionProperties(params.trackingData), + }); + // Get fee discount from rewards const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); @@ -1940,6 +2080,8 @@ export class TradingService { ...(errorMessage && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }), + // Discovery attribution (TAT-3080) + ...this.#buildAttributionProperties(params.trackingData), }; // Track event once with all properties @@ -2146,12 +2288,17 @@ export class TradingService { [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, [PERPS_EVENT_PROPERTY.ACTION]: flipAction, [PERPS_EVENT_PROPERTY.ORDER_VALUE]: positionSize * executedPrice, + // MetaMask fee on flip trades (TAT-3146) + ...(trackingData?.metamaskFee !== undefined && { + [PERPS_EVENT_PROPERTY.METAMASK_FEE]: trackingData.metamaskFee, + }), ...(trackingData?.vipTier !== undefined && { [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, }), ...(trackingData?.vipDiscount !== undefined && { [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, }), + ...this.#buildAttributionProperties(trackingData), }, ); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index c9e4137ad31..bb10a7d6e9f 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -162,6 +162,16 @@ export type TrackingData = { // Chart library active when the trade was initiated (e.g., lightweight, advanced) chartLibrary?: string; + // Entry point / discovery attribution (TAT-3080). Propagated onto trade/close/ + // cancel/risk events as entry_point, discovery_source, perp_discovery_source. + entryPoint?: string; + discoverySource?: string; + perpDiscoverySource?: string; + + // HyperLiquid protocol fee rate (TAT-3149). Emitted as hl_fee_rate on trade + + // close events when present; omitted entirely when unavailable. + hlFeeRate?: number; + // Pay with any token: true when user paid with a custom token (not Perps balance) tradeWithToken?: boolean; mmPayTokenSelected?: string; // Token symbol when tradeWithToken is true @@ -178,12 +188,22 @@ export type TrackingData = { // TP/SL-specific tracking data for analytics events export type TPSLTrackingData = { direction: 'long' | 'short'; // Position direction - source: string; // Source of the TP/SL update (e.g., 'tp_sl_view', 'position_card') + /** + * @deprecated Source of the TP/SL update (e.g., 'tp_sl_view', 'position_card'). + * Prefer `entryPoint` / `discoverySource` / `perpDiscoverySource` for risk-event + * attribution (TAT-3080); `source` is retained for backward compatibility. + */ + source: string; positionSize: number; // Unsigned position size for metrics takeProfitPercentage?: number; // Take profit percentage from entry stopLossPercentage?: number; // Stop loss percentage from entry isEditingExistingPosition?: boolean; // true = editing existing position, false = creating for new order entryPrice?: number; // Entry price for percentage calculations + + // Entry point / discovery attribution (TAT-3080), propagated onto risk events. + entryPoint?: string; + discoverySource?: string; + perpDiscoverySource?: string; }; // MetaMask Perps API order parameters for PerpsController @@ -537,6 +557,8 @@ export type CancelOrderParams = { orderId: string; // Order ID to cancel symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') providerId?: PerpsProviderType; // Multi-provider: optional provider override for routing + // Optional tracking data for MetaMetrics events (e.g. discovery attribution) + trackingData?: TrackingData; }; export type CancelOrderResult = { @@ -1383,8 +1405,30 @@ export enum PerpsAnalyticsEvent { RiskManagement = 'Perp Risk Management', PerpsError = 'Perp Error', AccountSetup = 'Perp Account Setup', + // New funnel + search events (TAT-3084, TAT-3202). + // Names must match MetaMetrics/Mixpanel exactly; no other event names may be added. + TransactionConsidered = 'Perp Transaction Considered', + TradeQuoteReceived = 'Perp Trade Quote Received', + SearchQuery = 'Perp Search Query', + SearchResultTapped = 'Perp Search Result Tapped', + SearchAbandoned = 'Perp Search Abandoned', } +/** + * UTM / discovery attribution context for Perps analytics (TAT-3133, TAT-3140). + * + * Held transiently in-memory by PerpsController (never persisted in state) and + * merged into analytics event properties so client-originated UTM attribution + * can be propagated onto core-emitted transaction events. + */ +export type PerpsAttributionContext = { + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; +}; + /** * Perps-specific trace names. These must match TraceName enum values in mobile. * When in core monorepo, this defines the valid trace names for Perps operations. diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 35ef732ba82..d594171cb45 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -574,6 +574,64 @@ describe('PerpsController', () => { mockInfrastructure.logger.error.mockClear(); mockInfrastructure.debugLogger.log.mockClear(); }); + describe('attribution context (TAT-3463)', () => { + it('returns an empty context by default', () => { + expect(controller.getAttributionContext()).toStrictEqual({}); + }); + + it('stores and returns the UTM attribution context', () => { + controller.setAttributionContext({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + }); + + expect(controller.getAttributionContext()).toStrictEqual({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + }); + }); + + it('clears the stored attribution context', () => { + controller.setAttributionContext({ utmSource: 'newsletter' }); + controller.clearAttributionContext(); + + expect(controller.getAttributionContext()).toStrictEqual({}); + }); + + it('merges defined UTM keys into event properties using canonical keys', () => { + controller.setAttributionContext({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + utmContent: 'cta', + utmTerm: 'perps', + }); + + expect(controller.mergeAttributionContext({ asset: 'BTC' })).toStrictEqual({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'newsletter', + [PERPS_EVENT_PROPERTY.UTM_MEDIUM]: 'email', + [PERPS_EVENT_PROPERTY.UTM_CAMPAIGN]: 'launch', + [PERPS_EVENT_PROPERTY.UTM_CONTENT]: 'cta', + [PERPS_EVENT_PROPERTY.UTM_TERM]: 'perps', + asset: 'BTC', + }); + }); + + it('lets provided properties win over attribution context and omits undefined UTM keys', () => { + controller.setAttributionContext({ utmSource: 'newsletter' }); + + expect( + controller.mergeAttributionContext({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override' }), + ).toStrictEqual({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override' }); + }); + + it('returns only base properties when no context is set', () => { + expect(controller.mergeAttributionContext()).toStrictEqual({}); + }); + }); + describe('state management', () => { it('returns positions without updating state', async () => { const mockPositions = [ diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts index 8c201f55075..a2d63667718 100644 --- a/packages/perps-controller/tests/src/constants/eventNames.test.ts +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -2,6 +2,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '../../../src/constants/eventNames'; +import { PerpsAnalyticsEvent } from '../../../src/types'; describe('PERPS_EVENT_PROPERTY', () => { describe('advanced chart analytics property keys', () => { @@ -14,6 +15,78 @@ describe('PERPS_EVENT_PROPERTY', () => { }); }); + describe('consolidated analytics contract property keys (TAT-3463)', () => { + it('exports entry point / discovery attribution keys (TAT-3080)', () => { + expect(PERPS_EVENT_PROPERTY.ENTRY_POINT).toBe('entry_point'); + expect(PERPS_EVENT_PROPERTY.DISCOVERY_SOURCE).toBe('discovery_source'); + expect(PERPS_EVENT_PROPERTY.PERP_DISCOVERY_SOURCE).toBe( + 'perp_discovery_source', + ); + }); + + it('exports UTM attribution keys (TAT-3133, TAT-3140)', () => { + expect(PERPS_EVENT_PROPERTY.UTM_SOURCE).toBe('utm_source'); + expect(PERPS_EVENT_PROPERTY.UTM_MEDIUM).toBe('utm_medium'); + expect(PERPS_EVENT_PROPERTY.UTM_CAMPAIGN).toBe('utm_campaign'); + expect(PERPS_EVENT_PROPERTY.UTM_CONTENT).toBe('utm_content'); + expect(PERPS_EVENT_PROPERTY.UTM_TERM).toBe('utm_term'); + }); + + it('exports watchlisted, hl_fee_rate, bulk_action_id, environment_type keys', () => { + expect(PERPS_EVENT_PROPERTY.WATCHLISTED).toBe('watchlisted'); + expect(PERPS_EVENT_PROPERTY.HL_FEE_RATE).toBe('hl_fee_rate'); + expect(PERPS_EVENT_PROPERTY.BULK_ACTION_ID).toBe('bulk_action_id'); + expect(PERPS_EVENT_PROPERTY.ENVIRONMENT_TYPE).toBe('environment_type'); + }); + + it('exports order funnel / quote keys (TAT-3084)', () => { + expect(PERPS_EVENT_PROPERTY.ORDER_CONTEXT).toBe('order_context'); + expect(PERPS_EVENT_PROPERTY.ORDER_SIZE_PERCENT).toBe('order_size_percent'); + expect(PERPS_EVENT_PROPERTY.LIMIT_PRICE_INPUT_TYPE).toBe( + 'limit_price_input_type', + ); + expect(PERPS_EVENT_PROPERTY.LIMIT_PRICE_INPUT_PRESET).toBe( + 'limit_price_input_preset', + ); + expect(PERPS_EVENT_PROPERTY.ORDER_HAS_TP).toBe('order_has_tp'); + expect(PERPS_EVENT_PROPERTY.ORDER_HAS_SL).toBe('order_has_sl'); + expect(PERPS_EVENT_PROPERTY.QUOTE_LATENCY_MS).toBe('quote_latency_ms'); + expect(PERPS_EVENT_PROPERTY.ERROR_REASON).toBe('error_reason'); + expect(PERPS_EVENT_PROPERTY.SAVED_ORDER).toBe('saved_order'); + expect(PERPS_EVENT_PROPERTY.DEFAULT_PAYMENT_TOKEN).toBe( + 'default_payment_token', + ); + expect(PERPS_EVENT_PROPERTY.DEFAULT_SIZE_AMOUNT).toBe( + 'default_size_amount', + ); + expect(PERPS_EVENT_PROPERTY.DEFAULT_LEVERAGE).toBe('default_leverage'); + expect(PERPS_EVENT_PROPERTY.DEFAULT_AUTO_CLOSE).toBe('default_auto_close'); + expect(PERPS_EVENT_PROPERTY.ORDER_EXECUTION_LATENCY_MS).toBe( + 'order_execution_latency_ms', + ); + expect(PERPS_EVENT_PROPERTY.SCREEN_CONTEXT).toBe('screen_context'); + expect(PERPS_EVENT_PROPERTY.FROM_TOKEN).toBe('from_token'); + expect(PERPS_EVENT_PROPERTY.FROM_CHAIN).toBe('from_chain'); + expect(PERPS_EVENT_PROPERTY.TO_TOKEN).toBe('to_token'); + expect(PERPS_EVENT_PROPERTY.TO_CHAIN).toBe('to_chain'); + }); + + it('exports search keys (TAT-3144, TAT-3202, TAT-3151)', () => { + expect(PERPS_EVENT_PROPERTY.SEARCH_QUERY).toBe('search_query'); + expect(PERPS_EVENT_PROPERTY.RESULTS_COUNT).toBe('results_count'); + expect(PERPS_EVENT_PROPERTY.RESULT_RANK).toBe('result_rank'); + expect(PERPS_EVENT_PROPERTY.MODE).toBe('mode'); + expect(PERPS_EVENT_PROPERTY.CURRENT_TOKEN).toBe('current_token'); + }); + + it('exports sort / filter and time-on-screen keys (TAT-3142, TAT-3136)', () => { + expect(PERPS_EVENT_PROPERTY.SORT_FIELD).toBe('sort_field'); + expect(PERPS_EVENT_PROPERTY.SORT_DIRECTION).toBe('sort_direction'); + expect(PERPS_EVENT_PROPERTY.FILTER_CATEGORY).toBe('filter_category'); + expect(PERPS_EVENT_PROPERTY.TIME_ON_SCREEN_MS).toBe('time_on_screen_ms'); + }); + }); + describe('discovery analytics property keys', () => { it('exports SOURCE_SECTION key', () => { expect(PERPS_EVENT_PROPERTY.SOURCE_SECTION).toBe('source_section'); @@ -250,3 +323,71 @@ describe('PERPS_EVENT_VALUE.BUTTON_LOCATION extensions', () => { ); }); }); + +describe('PERPS_EVENT_VALUE consolidated contract entries (TAT-3463)', () => { + it('exports new INTERACTION_TYPE values (TAT-3142, TAT-3144, TAT-3202, TAT-3151)', () => { + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SORT_APPLIED).toBe('sort_applied'); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.FILTER_APPLIED).toBe( + 'filter_applied', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_RESULT_TAPPED).toBe( + 'search_result_tapped', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_CHIP_TAPPED).toBe( + 'search_chip_tapped', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_SIGNAL_TILE_TAPPED).toBe( + 'search_signal_tile_tapped', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED).toBe( + 'payment_token_selector_dismissed', + ); + }); + + it('exports ACTION.ABANDON_ORDER (TAT-3136)', () => { + expect(PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER).toBe('abandon_order'); + }); + + it('exports new BUTTON_CLICKED values (TAT-3135, TAT-3141)', () => { + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.PLACE_ORDER).toBe('place_order'); + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE).toBe('close'); + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE).toBe( + 'reduce_exposure', + ); + }); + + it('exports new SCREEN_TYPE values and keeps add/remove margin (TAT-3144, TAT-3145)', () => { + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_RESULTS_SHOWN).toBe( + 'search_results_shown', + ); + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_NO_RESULTS).toBe( + 'search_no_results', + ); + // add_margin / remove_margin already existed — verify still present (TAT-3145) + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.ADD_MARGIN).toBe('add_margin'); + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.REMOVE_MARGIN).toBe('remove_margin'); + }); + + it('keeps STATUS.SUBMITTED for transaction pipeline events (TAT-3134)', () => { + expect(PERPS_EVENT_VALUE.STATUS.SUBMITTED).toBe('submitted'); + }); +}); + +describe('PerpsAnalyticsEvent (TAT-3463)', () => { + it('adds exactly the five new event names and keeps the nine existing', () => { + expect(PerpsAnalyticsEvent.TransactionConsidered).toBe( + 'Perp Transaction Considered', + ); + expect(PerpsAnalyticsEvent.TradeQuoteReceived).toBe( + 'Perp Trade Quote Received', + ); + expect(PerpsAnalyticsEvent.SearchQuery).toBe('Perp Search Query'); + expect(PerpsAnalyticsEvent.SearchResultTapped).toBe( + 'Perp Search Result Tapped', + ); + expect(PerpsAnalyticsEvent.SearchAbandoned).toBe('Perp Search Abandoned'); + + // No event names invented beyond the five new + nine existing = 14 total. + expect(Object.keys(PerpsAnalyticsEvent)).toHaveLength(14); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 3284f377983..f255a6bd3e6 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2451,4 +2451,320 @@ describe('TradingService', () => { }); }); }); + + describe('consolidated analytics pipeline (TAT-3463)', () => { + const mockClosePosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const findCall = (event: PerpsAnalyticsEvent, status: string) => + (mockDeps.metrics.trackPerpsEvent as jest.Mock).mock.calls.find( + ([calledEvent, props]) => + calledEvent === event && props.status === status, + ); + + describe('status=submitted before provider round-trip (TAT-3134)', () => { + it('emits a submitted trade event before placeOrder', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { symbol: 'BTC', isBuy: true, size: '0.1', orderType: 'market' }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted close event before closePosition', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted cancel event before cancelOrder', async () => { + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: { symbol: 'BTC', orderId: 'order-1' }, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted risk-management event before updatePositionTPSL', async () => { + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '60000', + trackingData: { + direction: 'long', + source: 'tp_sl_view', + positionSize: 0.5, + }, + }, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + }); + + it('populates metamask_fee on flip success from trackingData (TAT-3146)', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-1', + averagePrice: '50000', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + trackingData: { totalFee: 1, marketPrice: 50000, metamaskFee: 2.5 }, + context: mockContext, + }); + + expect(findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1]).toEqual( + expect.objectContaining({ metamask_fee: 2.5 }), + ); + }); + + it('adds leverage to the close event properties (TAT-3147)', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ leverage: 10 })); + }); + + describe('hl_fee_rate on trade + close (TAT-3149)', () => { + it('includes hl_fee_rate when present in trackingData', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { totalFee: 1, marketPrice: 50000, hlFeeRate: 0.00045 }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ hl_fee_rate: 0.00045 })); + }); + + it('omits hl_fee_rate when unavailable', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { totalFee: 1, marketPrice: 50000 }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).not.toHaveProperty('hl_fee_rate'); + }); + }); + + describe('bulk_action_id on batch close/cancel (TAT-3150)', () => { + it('attaches bulk_action_id to the batch close summary event', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ success: true, orderId: 'close-1', symbol: 'BTC' }], + }); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ bulk_action_id: 'mock-trace-id' }), + ); + }); + + it('attaches bulk_action_id to per-item close events in the fallback path', async () => { + (mockProvider as unknown as { closePositions?: unknown }).closePositions = + undefined; + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ bulk_action_id: 'mock-trace-id' })); + }); + + it('attaches bulk_action_id to the batch cancel summary event', async () => { + mockGetOpenOrders.mockResolvedValue([ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '50000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1, + } as Order, + ]); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ success: true, orderId: 'order-1', symbol: 'BTC' }], + }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params: { cancelAll: true }, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ bulk_action_id: 'mock-trace-id' }), + ); + }); + }); + + it('propagates entry_point/discovery_source/perp_discovery_source on trade events (TAT-3080)', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 1, + marketPrice: 50000, + entryPoint: 'perps_home', + discoverySource: 'watchlist', + perpDiscoverySource: 'top_movers', + }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual( + expect.objectContaining({ + entry_point: 'perps_home', + discovery_source: 'watchlist', + perp_discovery_source: 'top_movers', + }), + ); + }); + }); }); From 29a22d51dbc6ae390ddc3cf0735fb543c53c67bb Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 11:27:05 +0200 Subject: [PATCH 2/6] fix(perps-controller): regenerate action types and link changelog to PR 9311 Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 4 +- .../PerpsController-method-action-types.ts | 61 ++++++++++--------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index acf63b7f95f..0b73ceb3848 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Consolidate the Perps analytics contract so clients import a single source of truth from `@metamask/perps-controller` (TAT-3463) +- Consolidate the Perps analytics contract so clients import a single source of truth from `@metamask/perps-controller` ([#9311](https://github.com/MetaMask/core/pull/9311)) - Add five new `PerpsAnalyticsEvent` members: `TransactionConsidered` (`Perp Transaction Considered`), `TradeQuoteReceived` (`Perp Trade Quote Received`), `SearchQuery` (`Perp Search Query`), `SearchResultTapped` (`Perp Search Result Tapped`), `SearchAbandoned` (`Perp Search Abandoned`) - Add new `PERPS_EVENT_PROPERTY` keys: `entry_point`, `discovery_source`, `perp_discovery_source`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `watchlisted`, `hl_fee_rate`, `bulk_action_id`, `environment_type`, `order_context`, `order_size_percent`, `limit_price_input_type`, `limit_price_input_preset`, `order_has_tp`, `order_has_sl`, `quote_latency_ms`, `error_reason`, `saved_order`, `default_payment_token`, `default_size_amount`, `default_leverage`, `default_auto_close`, `order_execution_latency_ms`, `screen_context`, `from_token`, `from_chain`, `to_token`, `to_chain`, `search_query`, `results_count`, `result_rank`, `mode`, `current_token`, `sort_field`, `sort_direction`, `filter_category`, `time_on_screen_ms` - Add new `PERPS_EVENT_VALUE` entries: `INTERACTION_TYPE.{SORT_APPLIED, FILTER_APPLIED, SEARCH_RESULT_TAPPED, SEARCH_CHIP_TAPPED, SEARCH_SIGNAL_TILE_TAPPED, PAYMENT_TOKEN_SELECTOR_DISMISSED}`, `ACTION.ABANDON_ORDER`, `BUTTON_CLICKED.{PLACE_ORDER, CLOSE, REDUCE_EXPOSURE}`, `SCREEN_TYPE.{SEARCH_RESULTS_SHOWN, SEARCH_NO_RESULTS}` @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Consolidate the Perps transaction analytics pipeline in `TradingService` (TAT-3463) +- Consolidate the Perps transaction analytics pipeline in `TradingService` ([#9311](https://github.com/MetaMask/core/pull/9311)) - Emit a `status: 'submitted'` event before the provider round-trip for trade (`placeOrder`), close (`closePosition`), cancel (`cancelOrder`) and risk-management (`updatePositionTPSL`) operations - Populate `metamask_fee` on successful `flipPosition` trades from `trackingData` - Add `leverage` to `Perp Position Close Transaction` event properties diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c1435422599..433afe96b93 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -546,6 +546,35 @@ export type PerpsControllerGetWithdrawalRoutesAction = { handler: PerpsController['getWithdrawalRoutes']; }; +/** + * Set the transient UTM / discovery attribution context (TAT-3133, TAT-3140). + * Replaces any previously set context. Held in-memory only — not persisted. + * + * @param context - The attribution context (UTM fields) to store. + */ +export type PerpsControllerSetAttributionContextAction = { + type: `PerpsController:setAttributionContext`; + handler: PerpsController['setAttributionContext']; +}; + +/** + * Get a copy of the current attribution context (TAT-3133, TAT-3140). + * + * @returns A shallow copy of the stored attribution context. + */ +export type PerpsControllerGetAttributionContextAction = { + type: `PerpsController:getAttributionContext`; + handler: PerpsController['getAttributionContext']; +}; + +/** + * Clear the stored attribution context (TAT-3133, TAT-3140). + */ +export type PerpsControllerClearAttributionContextAction = { + type: `PerpsController:clearAttributionContext`; + handler: PerpsController['clearAttributionContext']; +}; + /** * Toggle between testnet and mainnet * @@ -1026,30 +1055,6 @@ export type PerpsControllerIsCurrentlyReinitializingAction = { handler: PerpsController['isCurrentlyReinitializing']; }; -/** - * Set the transient UTM / discovery attribution context (TAT-3133, TAT-3140). - */ -export type PerpsControllerSetAttributionContextAction = { - type: `PerpsController:setAttributionContext`; - handler: PerpsController['setAttributionContext']; -}; - -/** - * Get the current UTM / discovery attribution context (TAT-3133, TAT-3140). - */ -export type PerpsControllerGetAttributionContextAction = { - type: `PerpsController:getAttributionContext`; - handler: PerpsController['getAttributionContext']; -}; - -/** - * Clear the stored UTM / discovery attribution context (TAT-3133, TAT-3140). - */ -export type PerpsControllerClearAttributionContextAction = { - type: `PerpsController:clearAttributionContext`; - handler: PerpsController['clearAttributionContext']; -}; - /** * Union of all PerpsController action types. */ @@ -1097,6 +1102,9 @@ export type PerpsControllerMethodActions = | PerpsControllerValidateClosePositionAction | PerpsControllerValidateWithdrawalAction | PerpsControllerGetWithdrawalRoutesAction + | PerpsControllerSetAttributionContextAction + | PerpsControllerGetAttributionContextAction + | PerpsControllerClearAttributionContextAction | PerpsControllerToggleTestnetAction | PerpsControllerSwitchProviderAction | PerpsControllerGetCurrentNetworkAction @@ -1140,7 +1148,4 @@ export type PerpsControllerMethodActions = | PerpsControllerToggleWatchlistMarketAction | PerpsControllerIsWatchlistMarketAction | PerpsControllerGetWatchlistMarketsAction - | PerpsControllerIsCurrentlyReinitializingAction - | PerpsControllerSetAttributionContextAction - | PerpsControllerGetAttributionContextAction - | PerpsControllerClearAttributionContextAction; + | PerpsControllerIsCurrentlyReinitializingAction; From 3e429f6779e42977d06e1c3698b644da3192503b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 12:20:42 +0200 Subject: [PATCH 3/6] fix(perps): propagate attribution on flip failure events --- .../src/services/TradingService.ts | 1 + .../tests/src/services/TradingService.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 5e7330509dd..341033a0e19 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -2342,6 +2342,7 @@ export class TradingService { ...(trackingData?.vipDiscount !== undefined && { [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, }), + ...this.#buildAttributionProperties(trackingData), }); this.#deps.tracer.endTrace({ diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index f255a6bd3e6..1d284ddb707 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2400,6 +2400,33 @@ describe('TradingService', () => { ); }); + it('propagates attribution properties on failure', async () => { + mockProvider.placeOrder.mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + trackingData: { + entryPoint: 'position_view', + discoverySource: 'banner', + hlFeeRate: 0.00045, + }, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + entry_point: 'position_view', + discovery_source: 'banner', + hl_fee_rate: 0.00045, + }), + ); + }); + it('updates state on success', async () => { const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; mockProvider.placeOrder.mockResolvedValue(mockResult); From 1d97624599cb06f4ff2397823356e24cf81e7705 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 13:21:48 +0200 Subject: [PATCH 4/6] fix(perps): emit submitted flip event and terminal close on missing position --- .../src/services/TradingService.ts | 56 ++++++++++++++++--- .../tests/src/services/TradingService.test.ts | 43 ++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 341033a0e19..1efe2d9fb39 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -897,15 +897,40 @@ export class TradingService { }): void { const { position, result, error, params, duration, bulkActionId } = options; - if (!position) { - return; - } - // Bulk action correlation id for batch close events (TAT-3150) const bulkActionProps: PerpsAnalyticsProperties = bulkActionId ? { [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId } : {}; + if (!position) { + // No local position record, yet closePosition already emitted a + // submitted event and the close may still complete at the provider. + // Emit a terminal (executed/failed) event so every submitted close has a + // matching outcome, even without position-derived metrics. + const status = + result?.success === true + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED; + const errorMessage = error?.message ?? result?.error; + + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.PositionCloseTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: status, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: + params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...(errorMessage && { + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }), + ...this.#buildAttributionProperties(params.trackingData), + ...bulkActionProps, + }, + ); + return; + } + const metrics = result ? this.#calculateCloseMetrics(position, params, result) : { @@ -2240,6 +2265,11 @@ export class TradingService { const flipSize = positionSize * 2; + // Direction-specific flip action, shared by the submitted and terminal events + const flipAction = isCurrentlyLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + // Create order params for flip // Use 2x position size: 1x to close current position + 1x to open opposite position. // Do not pass the position entry price as currentPrice: the provider must fetch @@ -2252,6 +2282,20 @@ export class TradingService { leverage: position.leverage?.value, }; + // Emit submitted event before the provider round-trip, keeping flip + // trades aligned with the consolidated placeOrder pipeline. + this.#trackSubmitted(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: oppositeDirection + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: 'market', + [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage?.value || 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + ...this.#buildAttributionProperties(trackingData), + }); + // Place flip order (HyperLiquid handles margin transfer automatically) const result = await provider.placeOrder(orderParams); @@ -2270,10 +2314,6 @@ export class TradingService { } // Track success analytics with direction-specific flip action - const flipAction = isCurrentlyLong - ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT - : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; - this.#deps.metrics.trackPerpsEvent( PerpsAnalyticsEvent.TradeTransaction, { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 1d284ddb707..3ca345baccb 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2579,6 +2579,49 @@ describe('TradingService', () => { expect.objectContaining({ status: 'submitted', asset: 'BTC' }), ); }); + + it('emits a submitted trade event before flipPosition', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-1', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + }); + + it('emits a terminal close event when no local position is found', async () => { + mockGetPositions.mockResolvedValue([]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // Submitted event still fires, and a matching terminal event is emitted + // even though no local position metrics are available. + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ asset: 'BTC', status: 'executed' })); }); it('populates metamask_fee on flip success from trackingData (TAT-3146)', async () => { From 17b710dd66a7b52bc75a642a7ef9df79feeb6011 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 15:56:37 +0200 Subject: [PATCH 5/6] fix(perps): emit terminal failed event on non-throw flip rejection --- .../src/services/TradingService.ts | 17 +++++++++++++++ .../tests/src/services/TradingService.test.ts | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 1efe2d9fb39..f6fc1bad216 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -2345,6 +2345,23 @@ export class TradingService { // Invalidate standalone caches so external hooks refresh this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } else { + // Provider rejected the flip without throwing: emit a terminal failed + // event so every submitted flip is paired with executed or failed. + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: result.error ?? 'Unknown error', + ...(trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, + }), + ...(trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, + }), + ...this.#buildAttributionProperties(trackingData), + }); } this.#deps.tracer.endTrace({ diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 3ca345baccb..c863f5f07ec 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2597,6 +2597,27 @@ describe('TradingService', () => { expect.objectContaining({ status: 'submitted', asset: 'BTC' }), ); }); + + it('emits a terminal failed event when the flip is rejected without throwing', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'insufficient margin', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + context: mockContext, + }); + + expect(findCall(PerpsAnalyticsEvent.TradeTransaction, 'failed')?.[1]).toEqual( + expect.objectContaining({ + status: 'failed', + asset: 'BTC', + error_message: 'insufficient margin', + }), + ); + }); }); it('emits a terminal close event when no local position is found', async () => { From 155e0c51c1b9815d991f1a85cfd743a9cd182d7f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 30 Jun 2026 16:46:56 +0200 Subject: [PATCH 6/6] style(perps-controller): format analytics pipeline files with oxfmt Resolves the lint:misc:check CI failure by bringing the consolidated analytics/transaction-pipeline files into oxfmt compliance. Formatting only; no behavior or API changes. --- .../src/services/TradingService.ts | 32 ++++++++++-------- .../tests/src/PerpsController.state.test.ts | 8 +++-- .../tests/src/constants/eventNames.test.ts | 18 ++++++---- .../tests/src/services/TradingService.test.ts | 33 ++++++++++++++----- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f6fc1bad216..de1793292c2 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -2348,20 +2348,24 @@ export class TradingService { } else { // Provider rejected the flip without throwing: emit a terminal failed // event so every submitted flip is paired with executed or failed. - this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, - [PERPS_EVENT_PROPERTY.ACTION]: flipAction, - [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, - [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: result.error ?? 'Unknown error', - ...(trackingData?.vipTier !== undefined && { - [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, - }), - ...(trackingData?.vipDiscount !== undefined && { - [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, - }), - ...this.#buildAttributionProperties(trackingData), - }); + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + result.error ?? 'Unknown error', + ...(trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, + }), + ...(trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, + }), + ...this.#buildAttributionProperties(trackingData), + }, + ); } this.#deps.tracer.endTrace({ diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index d594171cb45..10a0691fffc 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -609,7 +609,9 @@ describe('PerpsController', () => { utmTerm: 'perps', }); - expect(controller.mergeAttributionContext({ asset: 'BTC' })).toStrictEqual({ + expect( + controller.mergeAttributionContext({ asset: 'BTC' }), + ).toStrictEqual({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'newsletter', [PERPS_EVENT_PROPERTY.UTM_MEDIUM]: 'email', [PERPS_EVENT_PROPERTY.UTM_CAMPAIGN]: 'launch', @@ -623,7 +625,9 @@ describe('PerpsController', () => { controller.setAttributionContext({ utmSource: 'newsletter' }); expect( - controller.mergeAttributionContext({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override' }), + controller.mergeAttributionContext({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override', + }), ).toStrictEqual({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override' }); }); diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts index a2d63667718..46c72fc023e 100644 --- a/packages/perps-controller/tests/src/constants/eventNames.test.ts +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -41,7 +41,9 @@ describe('PERPS_EVENT_PROPERTY', () => { it('exports order funnel / quote keys (TAT-3084)', () => { expect(PERPS_EVENT_PROPERTY.ORDER_CONTEXT).toBe('order_context'); - expect(PERPS_EVENT_PROPERTY.ORDER_SIZE_PERCENT).toBe('order_size_percent'); + expect(PERPS_EVENT_PROPERTY.ORDER_SIZE_PERCENT).toBe( + 'order_size_percent', + ); expect(PERPS_EVENT_PROPERTY.LIMIT_PRICE_INPUT_TYPE).toBe( 'limit_price_input_type', ); @@ -60,7 +62,9 @@ describe('PERPS_EVENT_PROPERTY', () => { 'default_size_amount', ); expect(PERPS_EVENT_PROPERTY.DEFAULT_LEVERAGE).toBe('default_leverage'); - expect(PERPS_EVENT_PROPERTY.DEFAULT_AUTO_CLOSE).toBe('default_auto_close'); + expect(PERPS_EVENT_PROPERTY.DEFAULT_AUTO_CLOSE).toBe( + 'default_auto_close', + ); expect(PERPS_EVENT_PROPERTY.ORDER_EXECUTION_LATENCY_MS).toBe( 'order_execution_latency_ms', ); @@ -326,7 +330,9 @@ describe('PERPS_EVENT_VALUE.BUTTON_LOCATION extensions', () => { describe('PERPS_EVENT_VALUE consolidated contract entries (TAT-3463)', () => { it('exports new INTERACTION_TYPE values (TAT-3142, TAT-3144, TAT-3202, TAT-3151)', () => { - expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SORT_APPLIED).toBe('sort_applied'); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SORT_APPLIED).toBe( + 'sort_applied', + ); expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.FILTER_APPLIED).toBe( 'filter_applied', ); @@ -339,9 +345,9 @@ describe('PERPS_EVENT_VALUE consolidated contract entries (TAT-3463)', () => { expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_SIGNAL_TILE_TAPPED).toBe( 'search_signal_tile_tapped', ); - expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED).toBe( - 'payment_token_selector_dismissed', - ); + expect( + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + ).toBe('payment_token_selector_dismissed'); }); it('exports ACTION.ABANDON_ORDER (TAT-3136)', () => { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index c863f5f07ec..8344938d1d8 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2511,7 +2511,12 @@ describe('TradingService', () => { await tradingService.placeOrder({ provider: mockProvider, - params: { symbol: 'BTC', isBuy: true, size: '0.1', orderType: 'market' }, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }, context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, }); @@ -2610,7 +2615,9 @@ describe('TradingService', () => { context: mockContext, }); - expect(findCall(PerpsAnalyticsEvent.TradeTransaction, 'failed')?.[1]).toEqual( + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'failed')?.[1], + ).toEqual( expect.objectContaining({ status: 'failed', asset: 'BTC', @@ -2659,9 +2666,9 @@ describe('TradingService', () => { context: mockContext, }); - expect(findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1]).toEqual( - expect.objectContaining({ metamask_fee: 2.5 }), - ); + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ metamask_fee: 2.5 })); }); it('adds leverage to the close event properties (TAT-3147)', async () => { @@ -2701,7 +2708,11 @@ describe('TradingService', () => { isBuy: true, size: '0.1', orderType: 'market', - trackingData: { totalFee: 1, marketPrice: 50000, hlFeeRate: 0.00045 }, + trackingData: { + totalFee: 1, + marketPrice: 50000, + hlFeeRate: 0.00045, + }, }, context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, @@ -2762,8 +2773,9 @@ describe('TradingService', () => { }); it('attaches bulk_action_id to per-item close events in the fallback path', async () => { - (mockProvider as unknown as { closePositions?: unknown }).closePositions = - undefined; + ( + mockProvider as unknown as { closePositions?: unknown } + ).closePositions = undefined; mockGetPositions.mockResolvedValue([mockClosePosition]); mockProvider.closePosition.mockResolvedValue({ success: true, @@ -2779,7 +2791,10 @@ describe('TradingService', () => { }); expect( - findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + findCall( + PerpsAnalyticsEvent.PositionCloseTransaction, + 'executed', + )?.[1], ).toEqual(expect.objectContaining({ bulk_action_id: 'mock-trace-id' })); });