Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
04ffb05
feat(sdk-analytics): add ANALYTICS_TRACKED_RPC_METHODS constant
wenfix Apr 1, 2026
39a2b91
feat(sdk-analytics): remove unconditional analytics.enable() from SDK…
wenfix Apr 1, 2026
4491e7b
feat(sdk-analytics): migrate connectToChannel events to MetaMetrics
wenfix Apr 1, 2026
55413c7
feat(sdk-analytics): migrate handleConnectionMessage events to MetaMe…
wenfix Apr 1, 2026
c7cec8a
feat(sdk-analytics): migrate handleSendMessage events to MetaMetrics
wenfix Apr 1, 2026
a0fb0e9
feat(sdk-analytics): remove @metamask/sdk-analytics dependency
wenfix Apr 1, 2026
eed168e
feat(sdk-analytics): define SDK wallet events in MetaMetricsEvents
wenfix Apr 2, 2026
9052d93
fix(sdk-analytics): address code review issues
wenfix Apr 2, 2026
3053876
Merge branch 'main' into wapi-1375
wenfix Apr 2, 2026
ced9071
fix(sdk-analytics): rename events to follow segment-schema conventions
adonesky1 Apr 6, 2026
78752e4
fix(sdk-analytics): rename MMConnect → Remote Connect Request Received
adonesky1 Apr 6, 2026
9548357
refactor: rename SDK RPC Request → Remote Connection RPC Request
adonesky1 Apr 7, 2026
fdb9828
refactor: remove redundant wallet_connection_user_approved/rejected V…
adonesky1 Apr 8, 2026
bda0739
refactor: align event names and properties with Segment schema
adonesky1 Apr 9, 2026
4e4725d
fix: move remote_session_id from sensitiveProperties to regular prope…
adonesky1 Apr 10, 2026
2d8786d
chore: use contants instead of hardcoded event names
wenfix Apr 13, 2026
f236a8f
chore: add revokePermissions and watchAsset to tracked methods
wenfix Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/core/Analytics/MetaMetrics.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ enum EVENT_NAME {
CONNECT_REQUEST_OTPFAILURE = 'Connect Request OTP Failure',
CONNECT_REQUEST_CANCELLED = 'Connect Request Cancelled',

// Remote connection events (SDK v1 socket relay, MWP, and WalletConnect)
REMOTE_CONNECTION_REQUEST_RECEIVED = 'Remote Connection Request Received',

// Remote connection RPC events (all remote transports)
REMOTE_CONNECTION_RPC_REQUEST_RECEIVED = 'Remote Connection RPC Request Received',
REMOTE_CONNECTION_RPC_REQUEST_APPROVED = 'Remote Connection RPC Request Approved',
REMOTE_CONNECTION_RPC_REQUEST_REJECTED = 'Remote Connection RPC Request Rejected',

// Phishing
PHISHING_PAGE_DISPLAYED = 'Phishing Page Displayed',
PROCEED_ANYWAY_CLICKED = 'Proceed Anyway Clicked',
Expand Down Expand Up @@ -774,6 +782,22 @@ const events = {
),
CONNECT_REQUEST_CANCELLED: generateOpt(EVENT_NAME.CONNECT_REQUEST_CANCELLED),

// Remote connection events (SDK v1 socket relay, MWP, and WalletConnect)
REMOTE_CONNECTION_REQUEST_RECEIVED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_REQUEST_RECEIVED,
),

// Remote connection RPC events (all remote transports)
REMOTE_CONNECTION_RPC_REQUEST_RECEIVED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_RPC_REQUEST_RECEIVED,
),
REMOTE_CONNECTION_RPC_REQUEST_APPROVED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_RPC_REQUEST_APPROVED,
),
REMOTE_CONNECTION_RPC_REQUEST_REJECTED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_RPC_REQUEST_REJECTED,
),

// Phishing events
PHISHING_PAGE_DISPLAYED: generateOpt(EVENT_NAME.PHISHING_PAGE_DISPLAYED),
PROCEED_ANYWAY_CLICKED: generateOpt(EVENT_NAME.PROCEED_ANYWAY_CLICKED),
Expand Down
72 changes: 16 additions & 56 deletions app/core/SDKConnect/ConnectionManagement/connectToChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ jest.mock('../utils/DevLogger');
jest.mock('../SDKConnectConstants');
jest.mock('../handlers/checkPermissions', () => jest.fn());

jest.mock('@metamask/sdk-analytics', () => ({
jest.mock('../../../util/analytics/analytics', () => ({
analytics: {
track: jest.fn(),
trackEvent: jest.fn(),
},
}));

// Import the mocked checkPermissions
import { OriginatorInfo } from '@metamask/sdk-communication-layer';
import { analytics } from '@metamask/sdk-analytics';
import { analytics } from '../../../util/analytics/analytics';
import {
NavigationContainerRef,
ParamListBase,
Expand Down Expand Up @@ -260,7 +260,7 @@ describe('connectToChannel', () => {
});

describe('Analytics', () => {
it('should track wallet_connection_request_received when anonId is present', async () => {
it('should track Remote Connection Request Received when anonId is present', async () => {
originatorInfo.anonId = 'test-anon-id';
(checkPermissions as jest.Mock).mockResolvedValue(true); // Ensure checkPermissions resolves

Expand All @@ -275,60 +275,20 @@ describe('connectToChannel', () => {
initialConnection: true,
});

expect(analytics.track).toHaveBeenCalledWith(
'wallet_connection_request_received',
{ anon_id: 'test-anon-id' },
expect(analytics.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Remote Connection Request Received',
properties: expect.objectContaining({
transport_type: 'socket_relay',
remote_session_id: 'test-anon-id',
}),
}),
);
});

it('should track wallet_connection_user_approved when checkPermissions resolves', async () => {
originatorInfo.anonId = 'test-anon-id';
(checkPermissions as jest.Mock).mockResolvedValue(true);

await connectToChannel({
instance: mockInstance,
id,
trigger,
otherPublicKey,
origin,
validUntil,
originatorInfo,
initialConnection: true,
});

expect(analytics.track).toHaveBeenCalledWith(
'wallet_connection_user_approved',
{ anon_id: 'test-anon-id' },
);
});

it('should track wallet_connection_user_rejected when checkPermissions rejects', async () => {
originatorInfo.anonId = 'test-anon-id';
(checkPermissions as jest.Mock).mockRejectedValue(
new Error('Permission denied'),
);

if (mockInstance.state.navigation) {
mockInstance.state.navigation.getCurrentRoute = jest
.fn()
.mockReturnValue({ name: 'rejection-test-route' });
}

await connectToChannel({
instance: mockInstance,
id,
trigger,
otherPublicKey,
origin,
validUntil,
originatorInfo,
initialConnection: true,
});

expect(analytics.track).toHaveBeenCalledWith(
'wallet_connection_user_rejected',
{ anon_id: 'test-anon-id' },
);
});
// wallet_connection_user_approved / wallet_connection_user_rejected are
// intentionally NOT tracked here — they are already covered by the
// MetaMetrics CONNECT_REQUEST_COMPLETED / CONNECT_REQUEST_CANCELLED events
// (with source: 'sdk') fired by the permission-system UI.
});
});
52 changes: 20 additions & 32 deletions app/core/SDKConnect/ConnectionManagement/connectToChannel.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { analytics } from '@metamask/sdk-analytics';
import {
MessageType,
SendAnalytics,
TrackingEvents,
} from '@metamask/sdk-communication-layer';
import { MessageType } from '@metamask/sdk-communication-layer';
import { MetaMetricsEvents } from '../../Analytics';
import { analytics } from '../../../util/analytics/analytics';
import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder';
import { resetConnections } from '../../../../app/actions/sdk';
import { store } from '../../../../app/store';
import Routes from '../../../constants/navigation/Routes';
Expand Down Expand Up @@ -78,9 +76,19 @@

if (anonId) {
DevLogger.log(
`[MM SDK Analytics] event=wallet_connection_request_received anonId=${anonId}`,
`[MM SDK Analytics] event=${MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED} anonId=${anonId}`,

Check warning on line 79 in app/core/SDKConnect/ConnectionManagement/connectToChannel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ2HKKHlyKYRgMO3eg8I&open=AZ2HKKHlyKYRgMO3eg8I&pullRequest=28322
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevLogger logs [object Object] instead of event name

Low Severity

MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED (and the RPC_REQUEST_RECEIVED variant) is an IMetaMetricsEvent object { category: 'Remote Connection Request Received' } returned by generateOpt. Interpolating it directly in a template literal produces [object Object] instead of the event name. handleSendMessage.ts correctly uses event.category for the same purpose—these two call sites need the same .category accessor.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f236a8f. Configure here.

);
analytics.trackEvent(
AnalyticsEventBuilder.createEventBuilder(
MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED,
)
.addProperties({
transport_type: 'socket_relay',
sdk_version: originatorInfo?.apiVersion,
remote_session_id: anonId,
})
.build(),
);
analytics.track('wallet_connection_request_received', { anon_id: anonId });
}

try {
Expand Down Expand Up @@ -175,38 +183,18 @@
res,
);
authorized = true;

if (anonId) {
DevLogger.log(
`[MM SDK Analytics] event=wallet_connection_user_approved anonId=${anonId}`,
);
analytics.track('wallet_connection_user_approved', {
anon_id: anonId,
});
}
} catch (error) {
DevLogger.log(
`SDKConnect::connectToChannel - checkPermissions - error`,
error,
);
if (anonId) {
DevLogger.log(
`[MM SDK Analytics] event=wallet_connection_user_rejected anonId=${anonId}`,
);
analytics.track('wallet_connection_user_rejected', {
anon_id: anonId,
});
}
// User approval/rejection is already tracked by the MetaMetrics
// CONNECT_REQUEST_COMPLETED / CONNECT_REQUEST_CANCELLED events
// (with source: 'sdk') fired by the permission-system UI.

// first needs to connect without key exchange to send the event
await instance.state.connected[id].remote.reject({ channelId: id });
// Send rejection event without awaiting
SendAnalytics(
{ id, event: TrackingEvents.REJECTED, ...originatorInfo },
instance.state.socketServerUrl,
).catch((err: Error) => {
Logger.error(err, 'SendAnalytics failed');
});

instance.removeChannel({ channelId: id, sendTerminate: true });
// cleanup connection
await wait(100); // Add delay for connect modal to be fully closed
Expand Down
3 changes: 0 additions & 3 deletions app/core/SDKConnect/SDKConnect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { NativeEventSubscription } from 'react-native';
import { analytics } from '@metamask/sdk-analytics';
import Logger from '../../util/Logger';
import AppConstants from '../AppConstants';

Expand Down Expand Up @@ -360,8 +359,6 @@ export class SDKConnect {
const navigation = NavigationService.navigation;
const instance = SDKConnect.getInstance();

analytics.setGlobalProperty('platform', 'mobile');
analytics.enable();
await init({ navigation, context, instance });
await instance.postInit();
}
Expand Down
19 changes: 19 additions & 0 deletions app/core/SDKConnect/SDKConnectConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,22 @@ export const METHODS_TO_DELAY: { [method: string]: boolean } = {
...METHODS_TO_REDIRECT,
[RPC_METHODS.ETH_REQUESTACCOUNTS]: false,
};

// RPC methods tracked for SDK wallet-side analytics (SDKv1 socket relay).
// Comparison is intentionally case-sensitive: RPC method names are standardised
// and the SDK is expected to send them in canonical casing.
Comment on lines +57 to +58
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Comparison is intentionally case-sensitive: RPC method names are standardised
// and the SDK is expected to send them in canonical casing.

export const ANALYTICS_TRACKED_RPC_METHODS: string[] = [
RPC_METHODS.ETH_SENDTRANSACTION,
RPC_METHODS.ETH_SIGNTYPEDEATA,
RPC_METHODS.ETH_SIGNTRANSACTION,
RPC_METHODS.PERSONAL_SIGN,
RPC_METHODS.WALLET_REQUESTPERMISSIONS,
RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN,
RPC_METHODS.ETH_SIGNTYPEDEATAV3,
RPC_METHODS.ETH_SIGNTYPEDEATAV4,
RPC_METHODS.METAMASK_CONNECTSIGN,
RPC_METHODS.METAMASK_CONNECTWITH,
RPC_METHODS.METAMASK_BATCH,
RPC_METHODS.WALLET_REVOKEPERMISSIONS,
RPC_METHODS.WALLET_WATCHASSET,
];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

56 changes: 24 additions & 32 deletions app/core/SDKConnect/handlers/handleConnectionMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NetworkController } from '@metamask/network-controller';
import {
CommunicationLayerMessage,
MessageType,
isAnalyticsTrackedRpcMethod,
OriginatorInfo,
} from '@metamask/sdk-communication-layer';
import Engine from '../../Engine';
Expand All @@ -23,18 +22,16 @@ import { AccountsController } from '@metamask/accounts-controller';
import { toChecksumHexAddress } from '@metamask/controller-utils';
import { NETWORKS_CHAIN_ID } from '../../../../app/constants/network';
import { mockNetworkState } from '../../../util/test/network';
import { analytics } from '@metamask/sdk-analytics';
import { analytics } from '../../../util/analytics/analytics';

jest.mock('@metamask/sdk-analytics', () => ({
jest.mock('../../../util/analytics/analytics', () => ({
analytics: {
track: jest.fn(),
trackEvent: jest.fn(),
},
}));

jest.mock('@metamask/sdk-communication-layer', () => ({
...jest.requireActual('@metamask/sdk-communication-layer'),
isAnalyticsTrackedRpcMethod: jest.fn(),
SendAnalytics: jest.fn(),
}));

jest.mock('../../Engine');
Expand Down Expand Up @@ -153,13 +150,9 @@ describe('handleConnectionMessage', () => {
mockHandleSendMessage.mockResolvedValue();
});

describe('Analytics tracking for wallet_action_received', () => {
const mockIsAnalyticsTrackedRpcMethod =
isAnalyticsTrackedRpcMethod as jest.Mock;

describe('Analytics tracking for Remote Connection RPC Request Received', () => {
beforeEach(() => {
mockIsAnalyticsTrackedRpcMethod.mockClear();
(analytics.track as jest.Mock).mockClear();
(analytics.trackEvent as jest.Mock).mockClear();

connection.originatorInfo = {
url: 'https://test-dapp.com',
Expand All @@ -173,47 +166,46 @@ describe('handleConnectionMessage', () => {
connector: 'metamask',
anonId: 'test-anon-id',
} as OriginatorInfo;
message.method = 'eth_requestAccounts';
message.method = 'eth_sendTransaction';
message.id = 'rpc-123';
message.type = MessageType.JSONRPC;
});

it('should track wallet_action_received when anonId is present and method is tracked', async () => {
mockIsAnalyticsTrackedRpcMethod.mockReturnValue(true);

it('should track Remote Connection RPC Request Received when anonId is present and method is tracked', async () => {
await handleConnectionMessage({ message, engine: Engine, connection });

expect(analytics.track).toHaveBeenCalledWith('wallet_action_received', {
anon_id: 'test-anon-id',
});
expect(analytics.track).toHaveBeenCalledTimes(1);
expect(mockIsAnalyticsTrackedRpcMethod).toHaveBeenCalledWith(
'eth_requestAccounts',
expect(analytics.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Remote Connection RPC Request Received',
properties: expect.objectContaining({
transport_type: 'socket_relay',
rpc_method: 'eth_sendTransaction',
remote_session_id: 'test-anon-id',
}),
}),
);
expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
});

it('should not track wallet_action_received if anonId is missing', async () => {
mockIsAnalyticsTrackedRpcMethod.mockReturnValue(true);
it('should not track Remote Connection RPC Request Received if anonId is missing', async () => {
if (connection.originatorInfo) {
connection.originatorInfo.anonId = undefined;
}

await handleConnectionMessage({ message, engine: Engine, connection });

expect(analytics.track).not.toHaveBeenCalled();
expect(analytics.trackEvent).not.toHaveBeenCalled();
});

it('should not track wallet_action_received if method is not analytics tracked', async () => {
mockIsAnalyticsTrackedRpcMethod.mockReturnValue(false);
it('should not track Remote Connection RPC Request Received if method is not analytics tracked', async () => {
message.method = 'eth_chainId';

await handleConnectionMessage({ message, engine: Engine, connection });

expect(analytics.track).not.toHaveBeenCalled();
expect(analytics.trackEvent).not.toHaveBeenCalled();
});

it('should not track wallet_action_received if message.method is undefined', async () => {
mockIsAnalyticsTrackedRpcMethod.mockReturnValue(true);

it('should not track Remote Connection RPC Request Received if message.method is undefined', async () => {
// Create a new message object for this specific test case to avoid type issues
const messageWithUndefinedMethod: CommunicationLayerMessage = {
// Explicitly define all required fields of CommunicationLayerMessage
Expand All @@ -231,7 +223,7 @@ describe('handleConnectionMessage', () => {
connection,
});

expect(analytics.track).not.toHaveBeenCalled();
expect(analytics.trackEvent).not.toHaveBeenCalled();
// The DevLogger for invalid message should be hit earlier in this case
expect(mockDevLoggerLog).toHaveBeenCalledWith(
`Connection::onMessage invalid message`,
Expand Down
Loading
Loading