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/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 25869ad7ba..97dfaa5636 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 ([#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 ([#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] ### 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..ebd40801b1 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1,3 +1,7 @@ +import type { + AnalyticsControllerGetStateAction, + AnalyticsControllerTrackEventAction, +} from '@metamask/analytics-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -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,48 @@ 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 +1451,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 as 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..461d56e301 --- /dev/null +++ b/packages/network-controller/src/rpc-service-events.test.ts @@ -0,0 +1,233 @@ +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..e9f50b55e5 --- /dev/null +++ b/packages/network-controller/src/rpc-service-events.ts @@ -0,0 +1,154 @@ +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..6044d39eb6 --- /dev/null +++ b/packages/network-controller/tests/NetworkController.analytics.test.ts @@ -0,0 +1,229 @@ +import type { Hex } from '@metamask/utils'; + +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'; + +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< + typeof buildNetworkControllerMessenger + >; +} { + 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"