diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index e0a29ed512d..50ff38f248f 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -67,6 +67,7 @@ enum EVENT_NAME { // Remote connection events (SDK v1 socket relay, MWP, and WalletConnect) REMOTE_CONNECTION_REQUEST_RECEIVED = 'Remote Connection Request Received', + REMOTE_CONNECTION_REQUEST_FAILED = 'Remote Connection Request Failed', // SDK v1 legacy RPC events (socket relay + deeplink protocol only) SDK_LEGACY_RPC_REQUEST_RECEIVED = 'SDK Legacy RPC Request Received', @@ -798,6 +799,9 @@ const events = { REMOTE_CONNECTION_REQUEST_RECEIVED: generateOpt( EVENT_NAME.REMOTE_CONNECTION_REQUEST_RECEIVED, ), + REMOTE_CONNECTION_REQUEST_FAILED: generateOpt( + EVENT_NAME.REMOTE_CONNECTION_REQUEST_FAILED, + ), // SDK v1 legacy RPC events (socket relay + deeplink protocol only) SDK_LEGACY_RPC_REQUEST_RECEIVED: generateOpt( diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts index 2ec67eb08fc..ddf22bddd1c 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts @@ -3,6 +3,15 @@ import { checkForDeeplink } from '../../../../../actions/user'; import ReduxService from '../../../../redux'; import Logger from '../../../../../util/Logger'; import { AppStateEventProcessor } from '../../../../AppStateEventListener'; +import SDKConnectV2 from '../../../../SDKConnectV2'; +import { analytics } from '../../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; +import { MetaMetricsEvents } from '../../../../Analytics/MetaMetrics.events'; +import { + DeepLinkRoute, + SignatureStatus, +} from '../../../types/deepLinkAnalytics.types'; +import { detectAppInstallation } from '../../../util/deeplinks/deepLinkAnalytics'; jest.mock('../../../../../actions/user', () => ({ checkForDeeplink: jest.fn(() => ({ type: 'CHECK_FOR_DEEPLINK' })), @@ -27,15 +36,49 @@ jest.mock('../../../../AppStateEventListener', () => ({ }, })); +jest.mock('../../../../SDKConnectV2', () => ({ + __esModule: true, + default: { + isMwpDeeplink: jest.fn(), + handleMwpDeeplink: jest.fn(), + }, +})); + +jest.mock('../../../../../util/analytics/analytics', () => ({ + analytics: { + trackEvent: jest.fn(), + }, +})); + +const mockBuild = jest.fn().mockReturnValue({ event: 'mocked' }); +const mockAddProperties = jest.fn().mockReturnValue({ build: mockBuild }); +jest.mock('../../../../../util/analytics/AnalyticsEventBuilder', () => ({ + AnalyticsEventBuilder: { + createEventBuilder: jest.fn().mockReturnValue({ + addProperties: (...args: unknown[]) => mockAddProperties(...args), + build: (...args: unknown[]) => mockBuild(...args), + }), + }, +})); + +jest.mock('../../../util/deeplinks/deepLinkAnalytics', () => ({ + detectAppInstallation: jest.fn(), +})); + describe('handleDeeplink', () => { const mockDispatch = ReduxService.store.dispatch as jest.Mock; const mockCheckForDeeplink = checkForDeeplink as jest.Mock; const mockLoggerError = Logger.error as jest.Mock; const mockSetCurrentDeeplink = AppStateEventProcessor.setCurrentDeeplink as jest.Mock; + const mockIsMwpDeeplink = SDKConnectV2.isMwpDeeplink as unknown as jest.Mock; + const mockHandleMwpDeeplink = SDKConnectV2.handleMwpDeeplink as jest.Mock; + const mockTrackEvent = analytics.trackEvent as jest.Mock; + const mockDetectAppInstallation = detectAppInstallation as jest.Mock; beforeEach(() => { jest.clearAllMocks(); + mockIsMwpDeeplink.mockReturnValue(false); }); it('processes valid URI and dispatch checkForDeeplink', () => { @@ -137,4 +180,90 @@ describe('handleDeeplink', () => { expect(mockDispatch).not.toHaveBeenCalled(); expect(mockCheckForDeeplink).not.toHaveBeenCalled(); }); + + describe('MWP deeplink handling', () => { + const mwpUri = 'metamask://mwp/some-compressed-payload'; + + beforeEach(() => { + mockIsMwpDeeplink.mockReturnValue(true); + }); + + it('routes MWP deeplinks to SDKConnectV2 and bypasses the standard flow', () => { + mockDetectAppInstallation.mockResolvedValue(true); + + handleDeeplink({ uri: mwpUri }); + + expect(mockIsMwpDeeplink).toHaveBeenCalledWith(mwpUri); + expect(mockHandleMwpDeeplink).toHaveBeenCalledWith(mwpUri); + expect(mockSetCurrentDeeplink).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockCheckForDeeplink).not.toHaveBeenCalled(); + }); + + it('fires DEEP_LINK_USED with route MMC_MWP and was_app_installed=true', async () => { + mockDetectAppInstallation.mockResolvedValue(true); + + handleDeeplink({ uri: mwpUri }); + + await flushPromises(); + + expect(mockDetectAppInstallation).toHaveBeenCalled(); + expect(AnalyticsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.DEEP_LINK_USED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + route: DeepLinkRoute.MMC_MWP, + signature: SignatureStatus.MISSING, + was_app_installed: true, + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mocked' }); + }); + + it('fires DEEP_LINK_USED with was_app_installed=false for fresh installs', async () => { + mockDetectAppInstallation.mockResolvedValue(false); + + handleDeeplink({ uri: mwpUri }); + + await flushPromises(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + route: DeepLinkRoute.MMC_MWP, + signature: SignatureStatus.MISSING, + was_app_installed: false, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mocked' }); + }); + + it('logs error when detectAppInstallation rejects', async () => { + const installError = new Error('install check failed'); + mockDetectAppInstallation.mockRejectedValue(installError); + + handleDeeplink({ uri: mwpUri }); + + await flushPromises(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith( + installError, + 'DeepLinkAnalytics: Failed to track MWP deep link event', + ); + }); + + it('still routes to SDKConnectV2 even if analytics fails', async () => { + mockDetectAppInstallation.mockRejectedValue(new Error('oops')); + + handleDeeplink({ uri: mwpUri }); + + expect(mockHandleMwpDeeplink).toHaveBeenCalledWith(mwpUri); + + await flushPromises(); + + expect(mockHandleMwpDeeplink).toHaveBeenCalledTimes(1); + }); + }); }); + +function flushPromises(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts b/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts index d632a6d3a4f..a3c054adfc1 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts @@ -3,12 +3,21 @@ import Logger from '../../../../util/Logger'; import { AppStateEventProcessor } from '../../../AppStateEventListener'; import ReduxService from '../../../redux'; import SDKConnectV2 from '../../../SDKConnectV2'; +import { analytics } from '../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { MetaMetricsEvents } from '../../../Analytics/MetaMetrics.events'; +import { + DeepLinkRoute, + SignatureStatus, +} from '../../types/deepLinkAnalytics.types'; +import { detectAppInstallation } from '../../util/deeplinks/deepLinkAnalytics'; export function handleDeeplink(opts: { uri?: string; source?: string }) { // This is the earliest JS entry point for deeplinks. We must handle SDKConnectV2 // links here immediately to establish the WebSocket connection as fast as possible, // without waiting for the app to be unlocked or fully onboarded. if (SDKConnectV2.isMwpDeeplink(opts.uri)) { + trackMwpDeepLinkUsed(); SDKConnectV2.handleMwpDeeplink(opts.uri); // By returning here, we bypass the standard saga-based deeplink flow below, // which would otherwise wait for a LOGIN or ONBOARDING_COMPLETED action. @@ -27,3 +36,29 @@ export function handleDeeplink(opts: { uri?: string; source?: string }) { Logger.error(e as Error, `Deeplink: Error parsing deeplink`); } } + +/** + * Fire DEEP_LINK_USED for MWP deeplinks asynchronously so the WebSocket + * handshake is never blocked by analytics work. + */ +function trackMwpDeepLinkUsed(): void { + detectAppInstallation() + .then((wasAppInstalled) => { + const event = AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.DEEP_LINK_USED, + ) + .addProperties({ + route: DeepLinkRoute.MMC_MWP, + signature: SignatureStatus.MISSING, + was_app_installed: wasAppInstalled, + }) + .build(); + analytics.trackEvent(event); + }) + .catch((error) => { + Logger.error( + error as Error, + 'DeepLinkAnalytics: Failed to track MWP deep link event', + ); + }); +} diff --git a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts index 3652503f7d7..01572377801 100644 --- a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts +++ b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts @@ -54,6 +54,7 @@ export enum DeepLinkRoute { CARD_ONBOARDING = 'card-onboarding', CARD_HOME = 'card-home', NFT = 'nft', + MMC_MWP = 'mmc-mwp', INVALID = 'invalid', } diff --git a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts index 71906caaac5..b19b1d2fdb2 100644 --- a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts +++ b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts @@ -419,6 +419,14 @@ const extractNftProperties = ( // NFT route doesn't have sensitive parameters to extract }; +const extractMmcMwpProperties = ( + _urlParams: UrlParamValues, + _sensitiveProps: Record, +): void => { + // MMC MWP deeplinks carry their payload in a compressed `p` param; + // no route-level sensitive properties to extract here. +}; + /** * Extract properties for INVALID route * No properties to extract, this function is a placeholder @@ -458,6 +466,7 @@ const routeExtractors: Record< [DeepLinkRoute.CARD_ONBOARDING]: extractCardOnboardingProperties, [DeepLinkRoute.CARD_HOME]: extractCardHomeProperties, [DeepLinkRoute.NFT]: extractNftProperties, + [DeepLinkRoute.MMC_MWP]: extractMmcMwpProperties, [DeepLinkRoute.INVALID]: extractInvalidProperties, }; diff --git a/app/core/SDKConnectV2/services/connection-registry.test.ts b/app/core/SDKConnectV2/services/connection-registry.test.ts index 465039f5544..7e8bacf8251 100644 --- a/app/core/SDKConnectV2/services/connection-registry.test.ts +++ b/app/core/SDKConnectV2/services/connection-registry.test.ts @@ -7,6 +7,9 @@ import { Connection } from './connection'; import { ConnectionRequest } from '../types/connection-request'; import { ConnectionInfo } from '../types/connection-info'; import Engine from '../../Engine'; +import { analytics } from '../../../util/analytics/analytics'; +import { MetaMetricsEvents } from '../../Analytics'; +import { TransportType } from '../../../components/hooks/useAnalytics/useAnalytics.types'; jest.mock('../adapters/host-application-adapter'); jest.mock('../store/connection-store'); @@ -15,6 +18,11 @@ jest.mock('./connection'); jest.mock('react-native'); jest.mock('@sentry/react-native'); jest.mock('../../Permissions'); +jest.mock('../../../util/analytics/analytics', () => ({ + analytics: { + trackEvent: jest.fn(), + }, +})); jest.mock('../../../store', () => ({ store: { dispatch: jest.fn(), @@ -24,6 +32,8 @@ jest.mock('../../../store', () => ({ }, })); +const mockTrackEvent = analytics.trackEvent as jest.Mock; + // Factory functions for creating mock objects // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMockConnection = (id: string, overrides: any = {}) => ({ @@ -285,6 +295,17 @@ describe('ConnectionRegistry', () => { expect(mockStore.get).toHaveBeenCalledWith('mock-conn-id'); expect(mockHostApp.showNotFoundError).not.toHaveBeenCalled(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.name).toBe('Remote Connection Request Received'); + expect(trackedEvent.properties).toEqual( + expect.objectContaining({ + remote_session_id: 'mock-conn-id', + transport_type: TransportType.MWP, + found_in_store: true, + }), + ); }); describe('when the connection is not found in the store', () => { @@ -296,11 +317,24 @@ describe('ConnectionRegistry', () => { mockStore, ); + const eventName = + MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED.category; await registry.handleSimpleDeeplink('mock-conn-id'); await jest.advanceTimersByTimeAsync(1000); expect(mockStore.get).toHaveBeenCalledWith('mock-conn-id'); expect(mockHostApp.showNotFoundError).toHaveBeenCalled(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.name).toBe(eventName); + expect(trackedEvent.properties).toEqual( + expect.objectContaining({ + remote_session_id: 'mock-conn-id', + transport_type: TransportType.MWP, + found_in_store: false, + }), + ); }); it('should show error if the keyring is not unlocked but becomes unlocked later', async () => { @@ -348,6 +382,9 @@ describe('ConnectionRegistry', () => { mockStore, ); + const eventName = + MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED.category; + await registry.handleConnectDeeplink(validDeeplink); // UI loading state is properly managed @@ -394,6 +431,19 @@ describe('ConnectionRegistry', () => { expiresAt: expect.any(Number), }), ); + + // Analytics: "received" event is tracked via MetaMetrics pipeline + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.name).toBe(eventName); + expect(trackedEvent.properties).toEqual( + expect.objectContaining({ + remote_session_id: mockConnectionRequest.sessionRequest.id, + transport_type: TransportType.MWP, + sdk_version: '2.0.0', + sdk_platform: 'JavaScript', + }), + ); }); it('should handle invalid URL gracefully', async () => { @@ -491,6 +541,8 @@ describe('ConnectionRegistry', () => { mockConnection.connect.mockRejectedValue(connectionError); const disconnectSpy = jest.spyOn(registry, 'disconnect'); + const eventName = + MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_FAILED.category; await registry.handleConnectDeeplink(validDeeplink); @@ -523,6 +575,18 @@ describe('ConnectionRegistry', () => { }), ); + // Analytics: both "received" and "failed" events are tracked + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + const failedEvent = mockTrackEvent.mock.calls[1][0]; + expect(failedEvent.name).toBe(eventName); + expect(failedEvent.properties).toEqual( + expect.objectContaining({ + remote_session_id: mockConnectionRequest.sessionRequest.id, + transport_type: TransportType.MWP, + failure_reason: 'Connection failed', + }), + ); + disconnectSpy.mockRestore(); }); diff --git a/app/core/SDKConnectV2/services/connection-registry.ts b/app/core/SDKConnectV2/services/connection-registry.ts index bb4b3112c1e..8e8770d2c96 100644 --- a/app/core/SDKConnectV2/services/connection-registry.ts +++ b/app/core/SDKConnectV2/services/connection-registry.ts @@ -18,6 +18,30 @@ import { whenStoreReady } from '../utils/when-store-ready'; import Engine from '../../Engine'; import { rpcErrors } from '@metamask/rpc-errors'; import { INTERNAL_ORIGINS } from '../../../constants/transaction'; +import { analytics } from '../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; +import type { IMetaMetricsEvent } from '../../Analytics/MetaMetrics.types'; +import { MetaMetricsEvents } from '../../Analytics/MetaMetrics.events'; +import { TransportType } from '../../../components/hooks/useAnalytics/useAnalytics.types'; + +/** + * Fire-and-forget analytics helper. Never throws — a broken analytics + * call must never abort connection establishment or error handling. + */ +function trackMwpEvent( + event: IMetaMetricsEvent, + properties: Record, +): void { + try { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder(event) + .addProperties(properties) + .build(), + ); + } catch { + // Intentionally swallowed: analytics must not block MWP flows. + } +} /** * Hard cap on the number of simultaneous active connections. @@ -151,9 +175,22 @@ export class ConnectionRegistry { const conn = await this.store.get(id); if (conn) { + trackMwpEvent(MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED, { + remote_session_id: conn.metadata?.analytics?.remote_session_id ?? id, + transport_type: TransportType.MWP, + sdk_version: conn.metadata?.sdk?.version, + sdk_platform: conn.metadata?.sdk?.platform, + found_in_store: true, + }); return; } + trackMwpEvent(MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED, { + remote_session_id: id, + transport_type: TransportType.MWP, + found_in_store: false, + }); + logger.error( 'Failed to find connection in store for simple deeplink with id:', id, @@ -206,9 +243,19 @@ export class ConnectionRegistry { let conn: Connection | undefined; let connInfo: ConnectionInfo | undefined; + let connReq: ConnectionRequest | undefined; try { - const connReq = this.parseConnectionRequest(url); + connReq = this.parseConnectionRequest(url); + + trackMwpEvent(MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED, { + remote_session_id: + connReq.metadata.analytics?.remote_session_id ?? + connReq.sessionRequest.id, + transport_type: TransportType.MWP, + sdk_version: connReq.metadata.sdk.version, + sdk_platform: connReq.metadata.sdk.platform, + }); // Defense-in-depth: block connections whose self-reported dapp metadata // matches a known internal origin. This check is currently redundant @@ -239,10 +286,25 @@ export class ConnectionRegistry { this.connections.set(conn.id, conn); await this.store.save(connInfo); this.hostapp.syncConnectionList(Array.from(this.connections.values())); + logger.debug('Handled connect deeplink.', connInfo?.id); } catch (error) { logger.error('Failed to handle connect deeplink:', error, redactUrl(url)); this.hostapp.showConnectionError(); + + // Track the failure before cleanup so the event fires even if + // disconnect() throws. + trackMwpEvent(MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_FAILED, { + remote_session_id: + connReq?.metadata?.analytics?.remote_session_id ?? + connReq?.sessionRequest?.id ?? + 'unknown', + transport_type: TransportType.MWP, + sdk_version: connReq?.metadata?.sdk?.version, + sdk_platform: connReq?.metadata?.sdk?.platform, + failure_reason: error instanceof Error ? error.message : String(error), + }); + if (conn) await this.disconnect(conn.id); } finally { if (connInfo) this.hostapp.hideConnectionLoading(connInfo);