From 536c2c18bc0e1331fa1df3c5b3bb3b71e3ef8473 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 25 Jun 2026 22:19:20 +0200 Subject: [PATCH 1/4] feat(network-controller): emit RPC service analytics via AnalyticsController NetworkController now emits "RPC Service Unavailable" and "RPC Service Degraded" analytics events itself through the AnalyticsController:trackEvent action, instead of relying on each client app to translate its rpcEndpointUnavailable/rpcEndpointDegraded events into MetaMetrics. The feature is opt in via a new optional `analytics` constructor option. When omitted, no analytics are emitted and AnalyticsController is never called. The two client specific pieces stay injected because core cannot know them: `isRpcEndpointUrlPublic` (depends on the client network lists) and `rpcServiceEventsSampleRate` (depends on the build environment). The generic property building, event naming, connection error skipping and delivery now live in core (src/rpc-service-events.ts), so both clients can drop their duplicated handlers in follow up PRs. --- packages/network-controller/CHANGELOG.md | 12 + packages/network-controller/package.json | 1 + .../src/NetworkController.ts | 143 ++++++++++- packages/network-controller/src/index.ts | 4 + .../src/rpc-service-events.test.ts | 242 ++++++++++++++++++ .../src/rpc-service-events.ts | 149 +++++++++++ .../tests/NetworkController.analytics.test.ts | 229 +++++++++++++++++ packages/network-controller/tests/helpers.ts | 17 ++ .../network-controller/tsconfig.build.json | 1 + packages/network-controller/tsconfig.json | 1 + yarn.lock | 3 +- 11 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 packages/network-controller/src/rpc-service-events.test.ts create mode 100644 packages/network-controller/src/rpc-service-events.ts create mode 100644 packages/network-controller/tests/NetworkController.analytics.test.ts diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 25869ad7ba..8bc5513a50 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `analytics` constructor option that makes `NetworkController` emit `RPC Service Unavailable` and `RPC Service Degraded` analytics events via the `AnalyticsController:trackEvent` action when an RPC endpoint becomes unavailable or degraded ([#0000](https://github.com/MetaMask/core/pull/0000)) + - The option takes `isRpcEndpointUrlPublic` (decides whether an endpoint URL is safe to report verbatim or as `'custom'`) and `rpcServiceEventsSampleRate` (the proportion of events to emit, between `0` and `1`). + - No analytics are emitted when the option is omitted. + - Adds the `NetworkControllerAnalyticsOptions` and `RpcServiceEventName` types. + +### Changed + +- The `NetworkControllerMessenger` now allows the `AnalyticsController:getState` and `AnalyticsController:trackEvent` actions ([#0000](https://github.com/MetaMask/core/pull/0000)) + - Consumers that pass the `analytics` option must delegate these actions to the network controller messenger. + ## [33.0.0] ### Added diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 8089532c1f..3edc505a71 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -53,6 +53,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/analytics-controller": "^1.2.0", "@metamask/base-controller": "^9.1.0", "@metamask/connectivity-controller": "^0.2.0", "@metamask/controller-utils": "^12.3.0", diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 09c727ada4..b723f96188 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -3,6 +3,10 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { + AnalyticsControllerGetStateAction, + AnalyticsControllerTrackEventAction, +} from '@metamask/analytics-controller'; import type { ConnectivityControllerGetStateAction } from '@metamask/connectivity-controller'; import type { Partialize } from '@metamask/controller-utils'; import { @@ -22,6 +26,7 @@ import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; import EthQuery from '@metamask/eth-query'; import type { Messenger } from '@metamask/messenger'; import { + generateDeterministicRandomNumber, RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; @@ -55,6 +60,15 @@ import { createAutoManagedNetworkClient } from './create-auto-managed-network-cl import type { DegradedEventType, RetryReason } from './create-network-client'; import { projectLogger, createModuleLogger } from './logger'; import type { NetworkControllerMethodActions } from './NetworkController-method-action-types'; +import { + buildRpcServiceEventProperties, + toAnalyticsTrackingEvent, +} from './rpc-service-events'; +import type { + NetworkControllerAnalyticsOptions, + RpcServiceEventName, +} from './rpc-service-events'; +import { isConnectionError } from './rpc-service/rpc-service'; import type { RpcServiceOptionsWithDefaults } from './rpc-service/rpc-service'; import { getIsRpcFailoverEnabled } from './selectors'; import { NetworkClientType } from './types'; @@ -710,7 +724,9 @@ export type NetworkControllerActions = */ type AllowedActions = | ConnectivityControllerGetStateAction - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + | AnalyticsControllerGetStateAction + | AnalyticsControllerTrackEventAction; export type NetworkControllerMessenger = Messenger< typeof controllerName, @@ -763,6 +779,15 @@ export type NetworkControllerOptions = { getBlockTrackerOptions?: ( rpcEndpointUrl: string, ) => Omit; + /** + * Optional configuration that, when provided, makes the controller emit + * "RPC Service Unavailable" and "RPC Service Degraded" analytics events via + * the `AnalyticsController:trackEvent` action whenever an RPC endpoint becomes + * unavailable or degraded. When omitted, no analytics are emitted and the + * `AnalyticsController` actions are never called. When provided, the messenger + * must allow `AnalyticsController:getState` and `AnalyticsController:trackEvent`. + */ + analytics?: NetworkControllerAnalyticsOptions; }; /** @@ -1268,6 +1293,8 @@ export class NetworkController extends BaseController< readonly #getBlockTrackerOptions: NetworkControllerOptions['getBlockTrackerOptions']; + readonly #analytics: NetworkControllerAnalyticsOptions | undefined; + #networkConfigurationsByNetworkClientId: Map< NetworkClientId, NetworkConfiguration @@ -1289,6 +1316,7 @@ export class NetworkController extends BaseController< log, getRpcServiceOptions, getBlockTrackerOptions, + analytics, } = options; const initialState = { ...getDefaultNetworkControllerState(), @@ -1332,6 +1360,7 @@ export class NetworkController extends BaseController< this.#log = log; this.#getRpcServiceOptions = getRpcServiceOptions; this.#getBlockTrackerOptions = getBlockTrackerOptions; + this.#analytics = analytics; this.#previouslySelectedNetworkClientId = this.state.selectedNetworkClientId; @@ -1370,6 +1399,44 @@ export class NetworkController extends BaseController< }, ); + if (this.#analytics) { + const analyticsOptions = this.#analytics; + this.messenger.subscribe( + `${this.name}:rpcEndpointUnavailable`, + ({ chainId, endpointUrl, error }) => { + this.#trackRpcServiceEvent(analyticsOptions, 'RPC Service Unavailable', { + chainId, + endpointUrl, + error, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointDegraded`, + ({ + chainId, + duration, + endpointUrl, + error, + retryReason, + rpcMethodName, + traceId, + type, + }) => { + this.#trackRpcServiceEvent(analyticsOptions, 'RPC Service Degraded', { + chainId, + duration, + endpointUrl, + error, + retryReason, + rpcMethodName, + traceId, + type, + }); + }, + ); + } + this.messenger.subscribe( // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', @@ -1380,6 +1447,80 @@ export class NetworkController extends BaseController< ); } + /** + * Emits an "RPC Service Unavailable" or "RPC Service Degraded" analytics event + * via the `AnalyticsController:trackEvent` action. + * + * Does nothing when analytics are not configured, when the error indicates a + * local connectivity issue, when there is no analytics ID, or when the event + * falls outside the configured sample. + * + * @param analytics - The analytics configuration. + * @param name - The analytics event name. + * @param payload - The relevant fields from the originating event. + * @param payload.chainId - The chain ID that the endpoint represents. + * @param payload.endpointUrl - The URL of the endpoint. + * @param payload.error - The connection or response error encountered. + * @param payload.duration - The policy execution time in milliseconds (degraded only). + * @param payload.retryReason - The category of error that was retried (degraded only). + * @param payload.rpcMethodName - The JSON-RPC method being executed (degraded only). + * @param payload.traceId - The `X-Trace-Id` response header value (degraded only). + * @param payload.type - Why the endpoint became degraded (degraded only). + */ + #trackRpcServiceEvent( + analytics: NetworkControllerAnalyticsOptions, + name: RpcServiceEventName, + payload: { + chainId: Hex; + endpointUrl: string; + error: unknown; + duration?: number; + retryReason?: RetryReason; + rpcMethodName?: string; + traceId?: string; + type?: DegradedEventType; + }, + ): void { + try { + if (isConnectionError(payload.error)) { + return; + } + + const { analyticsId } = this.messenger.call( + 'AnalyticsController:getState', + ); + if (!analyticsId) { + return; + } + + if ( + generateDeterministicRandomNumber(analyticsId) >= + analytics.rpcServiceEventsSampleRate + ) { + return; + } + + const properties = buildRpcServiceEventProperties({ + chainId: payload.chainId, + endpointUrl: payload.endpointUrl, + error: payload.error, + isRpcEndpointUrlPublic: analytics.isRpcEndpointUrlPublic, + duration: payload.duration, + retryReason: payload.retryReason, + rpcMethodName: payload.rpcMethodName, + traceId: payload.traceId, + type: payload.type, + }); + + this.messenger.call( + 'AnalyticsController:trackEvent', + toAnalyticsTrackingEvent(name, properties), + ); + } catch (error) { + this.messenger.captureException?.(error); + } + } + /** * Returns the EthQuery instance for the currently selected network. * diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 85d940d7c9..c1ba21b5a0 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -53,6 +53,10 @@ export type { AbstractRpcService } from './rpc-service/abstract-rpc-service'; export type { RpcServiceRequestable } from './rpc-service/rpc-service-requestable'; export type { DegradedEventType, RetryReason } from './create-network-client'; export { classifyRetryReason } from './create-network-client'; +export type { + NetworkControllerAnalyticsOptions, + RpcServiceEventName, +} from './rpc-service-events'; export { isConnectionError } from './rpc-service/rpc-service'; export type { NetworkControllerGetEthQueryAction, diff --git a/packages/network-controller/src/rpc-service-events.test.ts b/packages/network-controller/src/rpc-service-events.test.ts new file mode 100644 index 0000000000..dd30ec58b2 --- /dev/null +++ b/packages/network-controller/src/rpc-service-events.test.ts @@ -0,0 +1,242 @@ +import { + buildRpcServiceEventProperties, + sanitizeRpcEndpointUrl, + toAnalyticsTrackingEvent, +} from './rpc-service-events'; + +describe('sanitizeRpcEndpointUrl', () => { + it('returns the host of the URL when the endpoint is public', () => { + expect( + sanitizeRpcEndpointUrl('https://mainnet.infura.io/v3/the-key', true), + ).toBe('mainnet.infura.io'); + }); + + it('returns "custom" when the endpoint is not public', () => { + expect( + sanitizeRpcEndpointUrl('https://private.example.com/secret', false), + ).toBe('custom'); + }); + + it('returns "custom" when the endpoint is public but cannot be parsed', () => { + expect(sanitizeRpcEndpointUrl('not a url', true)).toBe('custom'); + }); +}); + +describe('buildRpcServiceEventProperties', () => { + const isRpcEndpointUrlPublic = (): boolean => true; + + it('builds the base properties from a public endpoint', () => { + const properties = buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }); + + expect(properties).toStrictEqual({ + + chain_id_caip: 'eip155:1', + + rpc_domain: 'mainnet.infura.io', + + rpc_endpoint_url: 'mainnet.infura.io', + }); + }); + + it('reports the domain as "custom" when the endpoint is not public', () => { + const properties = buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://private.example.com/secret', + error: undefined, + isRpcEndpointUrlPublic: () => false, + }); + + expect(properties).toMatchObject({ + + rpc_domain: 'custom', + + rpc_endpoint_url: 'custom', + }); + }); + + it('converts the chain ID to a CAIP decimal value', () => { + const properties = buildRpcServiceEventProperties({ + chainId: '0xe708', + endpointUrl: 'https://linea.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }); + + + expect(properties).toMatchObject({ chain_id_caip: 'eip155:59144' }); + }); + + it('includes rpc_method_name only when provided', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + rpcMethodName: 'eth_blockNumber', + }), + + ).toMatchObject({ rpc_method_name: 'eth_blockNumber' }); + + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('rpc_method_name'); + }); + + it('includes type only when provided', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + type: 'slow_success', + }), + ).toMatchObject({ type: 'slow_success' }); + + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('type'); + }); + + it('includes retry_reason only when provided', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + retryReason: 'connection-error', + }), + + ).toMatchObject({ retry_reason: 'connection-error' }); + + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('retry_reason'); + }); + + it('includes duration_ms only when duration is defined', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + duration: 1234, + }), + + ).toMatchObject({ duration_ms: 1234 }); + + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('duration_ms'); + }); + + it('includes trace_id only when defined', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + traceId: 'abc-123', + }), + + ).toMatchObject({ trace_id: 'abc-123' }); + + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: undefined, + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('trace_id'); + }); + + it('includes http_status when the error carries a JSON-serializable httpStatus', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: { httpStatus: 503 }, + isRpcEndpointUrlPublic, + }), + + ).toMatchObject({ http_status: 503 }); + }); + + it('omits http_status when the error has no httpStatus', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: new Error('boom'), + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('http_status'); + }); + + it('omits http_status when the error is not an object', () => { + expect( + buildRpcServiceEventProperties({ + chainId: '0x1', + endpointUrl: 'https://mainnet.infura.io/v3/the-key', + error: 'a string error', + isRpcEndpointUrlPublic, + }), + ).not.toHaveProperty('http_status'); + }); +}); + +describe('toAnalyticsTrackingEvent', () => { + it('wraps a name and properties into an analytics tracking event', () => { + const event = toAnalyticsTrackingEvent('RPC Service Degraded', { + + chain_id_caip: 'eip155:1', + }); + + expect(event).toStrictEqual({ + name: 'RPC Service Degraded', + + properties: { chain_id_caip: 'eip155:1' }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('reports hasProperties as false when there are no properties', () => { + expect(toAnalyticsTrackingEvent('RPC Service Unavailable', {})).toMatchObject( + { + hasProperties: false, + }, + ); + }); +}); diff --git a/packages/network-controller/src/rpc-service-events.ts b/packages/network-controller/src/rpc-service-events.ts new file mode 100644 index 0000000000..1dd3067219 --- /dev/null +++ b/packages/network-controller/src/rpc-service-events.ts @@ -0,0 +1,149 @@ +import type { AnalyticsTrackingEvent } from '@metamask/analytics-controller'; +import type { Hex, Json } from '@metamask/utils'; +import { hasProperty, hexToNumber, isObject, isValidJson } from '@metamask/utils'; + +import type { DegradedEventType, RetryReason } from './create-network-client'; + +/** + * The names of the analytics events that {@link NetworkController} emits when an + * RPC endpoint becomes unavailable or degraded. + */ +export type RpcServiceEventName = + | 'RPC Service Unavailable' + | 'RPC Service Degraded'; + +/** + * Configuration that enables {@link NetworkController} to emit analytics events + * for unavailable or degraded RPC endpoints. + * + * The pieces here are client-specific and cannot be derived inside the + * controller: deciding whether an endpoint URL is safe to report depends on the + * client's lists of known networks, and the sample rate depends on the client's + * build environment. + */ +export type NetworkControllerAnalyticsOptions = { + /** + * Returns `true` if the given RPC endpoint URL is safe to report verbatim (a + * "public" endpoint), or `false` if it must be reported as the literal string + * `'custom'` to avoid leaking private servers. + */ + isRpcEndpointUrlPublic: (endpointUrl: string) => boolean; + /** + * The proportion of events to emit, between 0 and 1. `1` emits every event, + * `0` emits none. Clients typically use a small value (e.g. `0.01`) in + * production to stay within their analytics quota, and `1` in development. + */ + rpcServiceEventsSampleRate: number; +}; + +/** + * Hides any API key contained in an RPC endpoint URL by reducing it to its + * host, but only when the endpoint is considered public. Non-public endpoints + * (and URLs that cannot be parsed) are reported as the literal string + * `'custom'`. + * + * @param endpointUrl - The URL of the RPC endpoint. + * @param isPublic - Whether the endpoint is safe to report verbatim. + * @returns The sanitized value to report. + */ +export function sanitizeRpcEndpointUrl( + endpointUrl: string, + isPublic: boolean, +): string { + if (!isPublic) { + return 'custom'; + } + + try { + return new URL(endpointUrl).host; + } catch { + return 'custom'; + } +} + +/** + * Builds the properties for an "RPC Service Unavailable" or "RPC Service + * Degraded" analytics event. + * + * @param args - The arguments. + * @param args.chainId - The chain ID that the endpoint represents. + * @param args.endpointUrl - The URL of the endpoint. + * @param args.error - The connection or response error encountered after making + * a request to the RPC endpoint. + * @param args.isRpcEndpointUrlPublic - Returns whether the endpoint URL is safe + * to report verbatim. + * @param args.duration - The policy execution time in milliseconds when the + * request succeeded but was slow (degraded events only). + * @param args.retryReason - The category of error that was retried (degraded + * events only). + * @param args.rpcMethodName - The JSON-RPC method that was being executed + * (degraded events only). + * @param args.traceId - The value of the `X-Trace-Id` response header from the + * last request attempt (degraded events only). + * @param args.type - Why the endpoint became degraded (degraded events only). + * @returns The analytics event properties. + */ +export function buildRpcServiceEventProperties({ + chainId, + endpointUrl, + error, + isRpcEndpointUrlPublic, + duration, + retryReason, + rpcMethodName, + traceId, + type, +}: { + chainId: Hex; + endpointUrl: string; + error: unknown; + isRpcEndpointUrlPublic: (endpointUrl: string) => boolean; + duration?: number; + retryReason?: RetryReason; + rpcMethodName?: string; + traceId?: string; + type?: DegradedEventType; +}): Record { + const sanitizedUrl = sanitizeRpcEndpointUrl( + endpointUrl, + isRpcEndpointUrlPublic(endpointUrl), + ); + + // The names of analytics properties have a particular case. + return { + chain_id_caip: `eip155:${hexToNumber(chainId)}`, + rpc_domain: sanitizedUrl, + rpc_endpoint_url: sanitizedUrl, // @deprecated - Will be removed in a future release. + ...(rpcMethodName ? { rpc_method_name: rpcMethodName } : {}), + ...(type ? { type } : {}), + ...(retryReason ? { retry_reason: retryReason } : {}), + ...(duration === undefined ? {} : { duration_ms: duration }), + ...(traceId === undefined ? {} : { trace_id: traceId }), + ...(isObject(error) && + hasProperty(error, 'httpStatus') && + isValidJson(error.httpStatus) + ? { http_status: error.httpStatus } + : {}), + }; +} + +/** + * Wraps an event name and properties into the shape expected by the + * `AnalyticsController:trackEvent` action. + * + * @param name - The analytics event name. + * @param properties - The analytics event properties. + * @returns The analytics tracking event. + */ +export function toAnalyticsTrackingEvent( + name: RpcServiceEventName, + properties: Record, +): AnalyticsTrackingEvent { + return { + name, + properties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: Object.keys(properties).length > 0, + }; +} diff --git a/packages/network-controller/tests/NetworkController.analytics.test.ts b/packages/network-controller/tests/NetworkController.analytics.test.ts new file mode 100644 index 0000000000..a062bfeb15 --- /dev/null +++ b/packages/network-controller/tests/NetworkController.analytics.test.ts @@ -0,0 +1,229 @@ +import type { Hex } from '@metamask/utils'; + +import { + buildNetworkControllerMessenger, + buildRootMessenger, +} from './helpers'; +import type { RootMessenger } from './helpers'; +import { NetworkController } from '../src'; +import type { NetworkControllerAnalyticsOptions } from '../src'; + +const PUBLIC_ENDPOINT_URL = 'https://mainnet.infura.io/v3/the-key'; + +const DEFAULT_ANALYTICS: NetworkControllerAnalyticsOptions = { + isRpcEndpointUrlPublic: () => true, + rpcServiceEventsSampleRate: 1, +}; + +const UNAVAILABLE_PAYLOAD = { + chainId: '0x1' as Hex, + endpointUrl: PUBLIC_ENDPOINT_URL, + error: undefined, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_ENDPOINT_URL, +}; + +const DEGRADED_PAYLOAD = { + chainId: '0x1' as Hex, + duration: 1234, + endpointUrl: PUBLIC_ENDPOINT_URL, + error: { httpStatus: 503 }, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_ENDPOINT_URL, + retryReason: 'connection-error' as const, + rpcMethodName: 'eth_blockNumber', + traceId: 'trace-1', + type: 'retries_exhausted' as const, +}; + +/** + * Builds a NetworkController wired to a messenger, without initializing it (the + * analytics subscriptions are registered in the constructor). + * + * @param args - The arguments. + * @param args.analytics - The analytics options to pass, or `undefined` to omit them. + * @param args.rootMessenger - The root messenger to use. + * @returns The controller and messengers. + */ +function buildController({ + analytics, + rootMessenger = buildRootMessenger(), +}: { + analytics?: NetworkControllerAnalyticsOptions; + rootMessenger?: RootMessenger; +}): { + controller: NetworkController; + rootMessenger: RootMessenger; + networkControllerMessenger: ReturnType; +} { + const networkControllerMessenger = + buildNetworkControllerMessenger(rootMessenger); + const controller = new NetworkController({ + messenger: networkControllerMessenger, + infuraProjectId: 'infura-project-id', + analytics, + }); + return { controller, rootMessenger, networkControllerMessenger }; +} + +describe('NetworkController analytics', () => { + it('emits "RPC Service Unavailable" when an endpoint becomes unavailable', () => { + const { networkControllerMessenger } = buildController({ + analytics: DEFAULT_ANALYTICS, + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(callSpy).toHaveBeenCalledWith('AnalyticsController:trackEvent', { + name: 'RPC Service Unavailable', + properties: { + + chain_id_caip: 'eip155:1', + + rpc_domain: 'mainnet.infura.io', + + rpc_endpoint_url: 'mainnet.infura.io', + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('emits "RPC Service Degraded" with the degraded-specific properties', () => { + const { networkControllerMessenger } = buildController({ + analytics: DEFAULT_ANALYTICS, + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointDegraded', + DEGRADED_PAYLOAD, + ); + + expect(callSpy).toHaveBeenCalledWith('AnalyticsController:trackEvent', { + name: 'RPC Service Degraded', + properties: { + + chain_id_caip: 'eip155:1', + + rpc_domain: 'mainnet.infura.io', + + rpc_endpoint_url: 'mainnet.infura.io', + + rpc_method_name: 'eth_blockNumber', + type: 'retries_exhausted', + + retry_reason: 'connection-error', + + duration_ms: 1234, + + trace_id: 'trace-1', + + http_status: 503, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('does not call AnalyticsController when analytics are not configured', () => { + const { networkControllerMessenger } = buildController({ + analytics: undefined, + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointDegraded', + DEGRADED_PAYLOAD, + ); + + expect(callSpy).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.anything(), + ); + }); + + it('does not emit when the error is a local connection error', () => { + const { networkControllerMessenger } = buildController({ + analytics: DEFAULT_ANALYTICS, + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + { ...UNAVAILABLE_PAYLOAD, error: new TypeError('network error') }, + ); + + expect(callSpy).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.anything(), + ); + }); + + it('does not emit when there is no analytics ID', () => { + const { networkControllerMessenger } = buildController({ + analytics: DEFAULT_ANALYTICS, + rootMessenger: buildRootMessenger({ analyticsId: '' }), + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(callSpy).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.anything(), + ); + }); + + it('does not emit when the event falls outside the sample', () => { + const { networkControllerMessenger } = buildController({ + analytics: { isRpcEndpointUrlPublic: () => true, rpcServiceEventsSampleRate: 0 }, + }); + const callSpy = jest.spyOn(networkControllerMessenger, 'call'); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(callSpy).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.anything(), + ); + }); + + it('captures the exception when delivering the event throws', () => { + const trackError = new Error('analytics blew up'); + const { rootMessenger, networkControllerMessenger } = buildController({ + analytics: { + isRpcEndpointUrlPublic: () => { + throw trackError; + }, + rpcServiceEventsSampleRate: 1, + }, + }); + const captureExceptionSpy = jest.spyOn(rootMessenger, 'captureException'); + + expect(() => { + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + }).not.toThrow(); + + expect(captureExceptionSpy).toHaveBeenCalledWith(trackError); + }); +}); diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index adfa80aeb9..8b76558695 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -1,3 +1,4 @@ +import { getDefaultAnalyticsControllerState } from '@metamask/analytics-controller'; import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import type { ConnectivityStatus } from '@metamask/connectivity-controller'; import { @@ -90,14 +91,18 @@ export const TESTNET = { * @param options.connectivityStatus - The connectivity status to return by default. * If not provided, defaults to Online. * @param options.isRpcFailoverEnabled - The RPC failover feature flag to return, defaults to false. + * @param options.analyticsId - The analytics ID that `AnalyticsController:getState` + * returns by default. Defaults to a fixed valid UUIDv4. * @returns The messenger. */ export function buildRootMessenger({ connectivityStatus = CONNECTIVITY_STATUSES.Online, isRpcFailoverEnabled = false, + analyticsId = '11111111-1111-4111-8111-111111111111', }: { connectivityStatus?: ConnectivityStatus; isRpcFailoverEnabled?: boolean; + analyticsId?: string; } = {}): RootMessenger { const rootMessenger = new Messenger< MockAnyNamespace, @@ -122,6 +127,16 @@ export function buildRootMessenger({ }), ); + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + ...getDefaultAnalyticsControllerState(), + analyticsId, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + jest.fn(), + ); + return rootMessenger; } @@ -149,6 +164,8 @@ export function buildNetworkControllerMessenger( actions: [ 'ConnectivityController:getState', 'RemoteFeatureFlagController:getState', + 'AnalyticsController:getState', + 'AnalyticsController:trackEvent', ], // eslint-disable-next-line no-restricted-syntax events: ['RemoteFeatureFlagController:stateChange'], diff --git a/packages/network-controller/tsconfig.build.json b/packages/network-controller/tsconfig.build.json index 5767f283bb..eaab8d986d 100644 --- a/packages/network-controller/tsconfig.build.json +++ b/packages/network-controller/tsconfig.build.json @@ -6,6 +6,7 @@ "rootDir": "./src" }, "references": [ + { "path": "../analytics-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../connectivity-controller/tsconfig.build.json" }, diff --git a/packages/network-controller/tsconfig.json b/packages/network-controller/tsconfig.json index 87d6f24f68..292dec5bbc 100644 --- a/packages/network-controller/tsconfig.json +++ b/packages/network-controller/tsconfig.json @@ -5,6 +5,7 @@ "rootDir": "../.." }, "references": [ + { "path": "../analytics-controller" }, { "path": "../base-controller" }, { "path": "../controller-utils" }, { "path": "../connectivity-controller" }, diff --git a/yarn.lock b/yarn.lock index d05fd4d3fb..2c1871fd10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5530,7 +5530,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/analytics-controller@workspace:packages/analytics-controller": +"@metamask/analytics-controller@npm:^1.2.0, @metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" dependencies: @@ -7661,6 +7661,7 @@ __metadata: resolution: "@metamask/network-controller@workspace:packages/network-controller" dependencies: "@json-rpc-specification/meta-schema": "npm:^1.0.6" + "@metamask/analytics-controller": "npm:^1.2.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/connectivity-controller": "npm:^0.2.0" From 7074dc3134575fad64e68ec6df58cd704fc805cc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 25 Jun 2026 22:20:26 +0200 Subject: [PATCH 2/4] docs(network-controller): link changelog entries to PR #9270 --- packages/network-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 8bc5513a50..97dfaa5636 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `analytics` constructor option that makes `NetworkController` emit `RPC Service Unavailable` and `RPC Service Degraded` analytics events via the `AnalyticsController:trackEvent` action when an RPC endpoint becomes unavailable or degraded ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add optional `analytics` constructor option that makes `NetworkController` emit `RPC Service Unavailable` and `RPC Service Degraded` analytics events via the `AnalyticsController:trackEvent` action when an RPC endpoint becomes unavailable or degraded ([#9270](https://github.com/MetaMask/core/pull/9270)) - The option takes `isRpcEndpointUrlPublic` (decides whether an endpoint URL is safe to report verbatim or as `'custom'`) and `rpcServiceEventsSampleRate` (the proportion of events to emit, between `0` and `1`). - No analytics are emitted when the option is omitted. - Adds the `NetworkControllerAnalyticsOptions` and `RpcServiceEventName` types. ### Changed -- The `NetworkControllerMessenger` now allows the `AnalyticsController:getState` and `AnalyticsController:trackEvent` actions ([#0000](https://github.com/MetaMask/core/pull/0000)) +- The `NetworkControllerMessenger` now allows the `AnalyticsController:getState` and `AnalyticsController:trackEvent` actions ([#9270](https://github.com/MetaMask/core/pull/9270)) - Consumers that pass the `analytics` option must delegate these actions to the network controller messenger. ## [33.0.0] From 8697e1f3d5ecf02ffcf9a1b6dcc4fd6f9269ef77 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 25 Jun 2026 22:46:34 +0200 Subject: [PATCH 3/4] fix(network-controller): type captureException arg and update dep graph The build tsconfig flagged passing the `unknown` catch variable to `captureException`, which expects an `Error`. Cast it, matching the other captureException call sites. Also regenerate the root README dependency graph to include the new analytics-controller edge. --- README.md | 1 + packages/network-controller/src/NetworkController.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 977fa90565..b868cfe9cf 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,7 @@ linkStyle default opacity:0.5 name_controller --> base_controller; name_controller --> controller_utils; name_controller --> messenger; + network_controller --> analytics_controller; network_controller --> base_controller; network_controller --> connectivity_controller; network_controller --> controller_utils; diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index b723f96188..cc86737603 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1517,7 +1517,7 @@ export class NetworkController extends BaseController< toAnalyticsTrackingEvent(name, properties), ); } catch (error) { - this.messenger.captureException?.(error); + this.messenger.captureException?.(error as Error); } } From 14cbc2873040bed71b049425fcc190ce83518b88 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 26 Jun 2026 11:17:02 +0200 Subject: [PATCH 4/4] style(network-controller): apply oxfmt formatting --- .../src/NetworkController.ts | 22 +++++++----- .../src/rpc-service-events.test.ts | 27 +++++--------- .../src/rpc-service-events.ts | 7 +++- .../tests/NetworkController.analytics.test.ts | 36 +++++++++---------- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index cc86737603..ebd40801b1 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1,12 +1,12 @@ +import type { + AnalyticsControllerGetStateAction, + AnalyticsControllerTrackEventAction, +} from '@metamask/analytics-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { - AnalyticsControllerGetStateAction, - AnalyticsControllerTrackEventAction, -} from '@metamask/analytics-controller'; import type { ConnectivityControllerGetStateAction } from '@metamask/connectivity-controller'; import type { Partialize } from '@metamask/controller-utils'; import { @@ -1404,11 +1404,15 @@ export class NetworkController extends BaseController< this.messenger.subscribe( `${this.name}:rpcEndpointUnavailable`, ({ chainId, endpointUrl, error }) => { - this.#trackRpcServiceEvent(analyticsOptions, 'RPC Service Unavailable', { - chainId, - endpointUrl, - error, - }); + this.#trackRpcServiceEvent( + analyticsOptions, + 'RPC Service Unavailable', + { + chainId, + endpointUrl, + error, + }, + ); }, ); this.messenger.subscribe( diff --git a/packages/network-controller/src/rpc-service-events.test.ts b/packages/network-controller/src/rpc-service-events.test.ts index dd30ec58b2..461d56e301 100644 --- a/packages/network-controller/src/rpc-service-events.test.ts +++ b/packages/network-controller/src/rpc-service-events.test.ts @@ -34,11 +34,10 @@ describe('buildRpcServiceEventProperties', () => { }); expect(properties).toStrictEqual({ - chain_id_caip: 'eip155:1', - + rpc_domain: 'mainnet.infura.io', - + rpc_endpoint_url: 'mainnet.infura.io', }); }); @@ -52,9 +51,8 @@ describe('buildRpcServiceEventProperties', () => { }); expect(properties).toMatchObject({ - rpc_domain: 'custom', - + rpc_endpoint_url: 'custom', }); }); @@ -67,7 +65,6 @@ describe('buildRpcServiceEventProperties', () => { isRpcEndpointUrlPublic, }); - expect(properties).toMatchObject({ chain_id_caip: 'eip155:59144' }); }); @@ -80,7 +77,6 @@ describe('buildRpcServiceEventProperties', () => { isRpcEndpointUrlPublic, rpcMethodName: 'eth_blockNumber', }), - ).toMatchObject({ rpc_method_name: 'eth_blockNumber' }); expect( @@ -123,7 +119,6 @@ describe('buildRpcServiceEventProperties', () => { isRpcEndpointUrlPublic, retryReason: 'connection-error', }), - ).toMatchObject({ retry_reason: 'connection-error' }); expect( @@ -145,7 +140,6 @@ describe('buildRpcServiceEventProperties', () => { isRpcEndpointUrlPublic, duration: 1234, }), - ).toMatchObject({ duration_ms: 1234 }); expect( @@ -167,7 +161,6 @@ describe('buildRpcServiceEventProperties', () => { isRpcEndpointUrlPublic, traceId: 'abc-123', }), - ).toMatchObject({ trace_id: 'abc-123' }); expect( @@ -188,7 +181,6 @@ describe('buildRpcServiceEventProperties', () => { error: { httpStatus: 503 }, isRpcEndpointUrlPublic, }), - ).toMatchObject({ http_status: 503 }); }); @@ -218,13 +210,12 @@ describe('buildRpcServiceEventProperties', () => { describe('toAnalyticsTrackingEvent', () => { it('wraps a name and properties into an analytics tracking event', () => { const event = toAnalyticsTrackingEvent('RPC Service Degraded', { - chain_id_caip: 'eip155:1', }); expect(event).toStrictEqual({ name: 'RPC Service Degraded', - + properties: { chain_id_caip: 'eip155:1' }, sensitiveProperties: {}, saveDataRecording: false, @@ -233,10 +224,10 @@ describe('toAnalyticsTrackingEvent', () => { }); it('reports hasProperties as false when there are no properties', () => { - expect(toAnalyticsTrackingEvent('RPC Service Unavailable', {})).toMatchObject( - { - hasProperties: false, - }, - ); + expect( + toAnalyticsTrackingEvent('RPC Service Unavailable', {}), + ).toMatchObject({ + hasProperties: false, + }); }); }); diff --git a/packages/network-controller/src/rpc-service-events.ts b/packages/network-controller/src/rpc-service-events.ts index 1dd3067219..e9f50b55e5 100644 --- a/packages/network-controller/src/rpc-service-events.ts +++ b/packages/network-controller/src/rpc-service-events.ts @@ -1,6 +1,11 @@ import type { AnalyticsTrackingEvent } from '@metamask/analytics-controller'; import type { Hex, Json } from '@metamask/utils'; -import { hasProperty, hexToNumber, isObject, isValidJson } from '@metamask/utils'; +import { + hasProperty, + hexToNumber, + isObject, + isValidJson, +} from '@metamask/utils'; import type { DegradedEventType, RetryReason } from './create-network-client'; diff --git a/packages/network-controller/tests/NetworkController.analytics.test.ts b/packages/network-controller/tests/NetworkController.analytics.test.ts index a062bfeb15..6044d39eb6 100644 --- a/packages/network-controller/tests/NetworkController.analytics.test.ts +++ b/packages/network-controller/tests/NetworkController.analytics.test.ts @@ -1,12 +1,9 @@ import type { Hex } from '@metamask/utils'; -import { - buildNetworkControllerMessenger, - buildRootMessenger, -} from './helpers'; -import type { RootMessenger } from './helpers'; import { NetworkController } from '../src'; import type { NetworkControllerAnalyticsOptions } from '../src'; +import { buildNetworkControllerMessenger, buildRootMessenger } from './helpers'; +import type { RootMessenger } from './helpers'; const PUBLIC_ENDPOINT_URL = 'https://mainnet.infura.io/v3/the-key'; @@ -54,7 +51,9 @@ function buildController({ }): { controller: NetworkController; rootMessenger: RootMessenger; - networkControllerMessenger: ReturnType; + networkControllerMessenger: ReturnType< + typeof buildNetworkControllerMessenger + >; } { const networkControllerMessenger = buildNetworkControllerMessenger(rootMessenger); @@ -81,11 +80,10 @@ describe('NetworkController analytics', () => { expect(callSpy).toHaveBeenCalledWith('AnalyticsController:trackEvent', { name: 'RPC Service Unavailable', properties: { - chain_id_caip: 'eip155:1', - + rpc_domain: 'mainnet.infura.io', - + rpc_endpoint_url: 'mainnet.infura.io', }, sensitiveProperties: {}, @@ -108,22 +106,21 @@ describe('NetworkController analytics', () => { expect(callSpy).toHaveBeenCalledWith('AnalyticsController:trackEvent', { name: 'RPC Service Degraded', properties: { - chain_id_caip: 'eip155:1', - + rpc_domain: 'mainnet.infura.io', - + rpc_endpoint_url: 'mainnet.infura.io', - + rpc_method_name: 'eth_blockNumber', type: 'retries_exhausted', - + retry_reason: 'connection-error', - + duration_ms: 1234, - + trace_id: 'trace-1', - + http_status: 503, }, sensitiveProperties: {}, @@ -190,7 +187,10 @@ describe('NetworkController analytics', () => { it('does not emit when the event falls outside the sample', () => { const { networkControllerMessenger } = buildController({ - analytics: { isRpcEndpointUrlPublic: () => true, rpcServiceEventsSampleRate: 0 }, + analytics: { + isRpcEndpointUrlPublic: () => true, + rpcServiceEventsSampleRate: 0, + }, }); const callSpy = jest.spyOn(networkControllerMessenger, 'call');