diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b89898400a3..ab8678a2617 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `trackMetaMetricsEvent` callback to measure and report first init/fetch historical time (duration in ms) to MetaMetrics when the initial asset fetch completes after unlock or app open ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) - Add basic functionality toggle: `isBasicFunctionality` (getter `() => boolean`); no value is stored in the controller. When the getter returns true (matches UI "Basic functionality" ON), token and price APIs are used; when false, only RPC is used. Optional `subscribeToBasicFunctionalityChange(onChange)` lets the consumer register for toggle changes (e.g. extension subscribes to PreferencesController:stateChange, mobile uses its own mechanism); may return an unsubscribe function for controller destroy ([#7904](https://github.com/MetaMask/core/pull/7904)) ### Changed diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 99be318b02e..1fa0f676b1c 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -13,6 +13,7 @@ import { getDefaultAssetsControllerState, } from './AssetsController'; import type { + AssetsControllerFirstInitFetchMetaMetricsPayload, AssetsControllerMessenger, AssetsControllerState, } from './AssetsController'; @@ -56,6 +57,12 @@ function createMockInternalAccount( type WithControllerOptions = { state?: Partial; isBasicFunctionality?: () => boolean; + /** Extra options passed to AssetsController constructor (e.g. trackMetaMetricsEvent). */ + controllerOptions?: Partial<{ + trackMetaMetricsEvent: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; + }>; }; type WithControllerCallback = ({ @@ -78,10 +85,15 @@ async function withController( | [WithControllerOptions, WithControllerCallback] | [WithControllerCallback] ): Promise { - const [{ state = {}, isBasicFunctionality = (): boolean => true }, fn]: [ - WithControllerOptions, - WithControllerCallback, - ] = args.length === 2 ? args : [{}, args[0]]; + const [ + { + state = {}, + isBasicFunctionality = (): boolean => true, + controllerOptions = {}, + }, + fn, + ]: [WithControllerOptions, WithControllerCallback] = + args.length === 2 ? args : [{}, args[0]]; // Use root messenger (MOCK_ANY_NAMESPACE) so data sources can register their actions. const messenger: RootMessenger = new Messenger({ @@ -138,6 +150,7 @@ async function withController( subscribeToBasicFunctionalityChange: (): void => { /* no-op for tests */ }, + ...controllerOptions, }); return fn({ controller, messenger }); @@ -728,6 +741,51 @@ describe('AssetsController', () => { expect(true).toBe(true); }); }); + + it('invokes trackMetaMetricsEvent with first init fetch duration on unlock', async () => { + const trackMetaMetricsEvent = jest.fn(); + + await withController( + { controllerOptions: { trackMetaMetricsEvent } }, + async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + + // Allow #start() -> getAssets() to resolve so the callback runs + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + durationMs: expect.any(Number), + chainIds: expect.any(Array), + durationByDataSource: expect.any(Object), + }), + ); + const payload = trackMetaMetricsEvent.mock + .calls[0][0] as AssetsControllerFirstInitFetchMetaMetricsPayload; + expect(payload.durationMs).toBeGreaterThanOrEqual(0); + expect(Array.isArray(payload.chainIds)).toBe(true); + expect(typeof payload.durationByDataSource).toBe('object'); + }, + ); + }); + + it('invokes trackMetaMetricsEvent only once per session until lock', async () => { + const trackMetaMetricsEvent = jest.fn(); + + await withController( + { controllerOptions: { trackMetaMetricsEvent } }, + async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1); + }, + ); + }); }); describe('subscribeAssetsPrice', () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7c825c14cd5..24f56193de5 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -58,6 +58,8 @@ import type { DataType, DataRequest, DataResponse, + FetchContext, + FetchNextFunction, NextFunction, Middleware, SubscriptionResponse, @@ -200,6 +202,23 @@ export type AssetsControllerMessenger = Messenger< // CONTROLLER OPTIONS // ============================================================================ +/** + * Payload for the first init/fetch MetaMetrics event. + * Passed to the optional trackMetaMetricsEvent callback when the initial + * asset fetch completes after unlock or app open. + */ +export type AssetsControllerFirstInitFetchMetaMetricsPayload = { + /** Duration of the first init fetch in milliseconds (wall-clock). */ + durationMs: number; + /** Chain IDs requested in the fetch (e.g. ['eip155:1', 'eip155:137']). */ + chainIds: string[]; + /** + * Exclusive latency in ms per data source (time spent in that source only). + * Sum of values approximates durationMs. Order: same as middleware chain. + */ + durationByDataSource: Record; +}; + export type AssetsControllerOptions = { messenger: AssetsControllerMessenger; state?: Partial; @@ -233,6 +252,13 @@ export type AssetsControllerOptions = { queryApiClient: ApiPlatformClient; /** Optional configuration for RpcDataSource. */ rpcDataSourceConfig?: RpcDataSourceConfig; + /** + * Optional callback invoked when the first init/fetch completes (e.g. after unlock). + * Use this to track first init fetch duration in MetaMetrics. + */ + trackMetaMetricsEvent?: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; }; // ============================================================================ @@ -384,6 +410,14 @@ export class AssetsController extends BaseController< /** Default update interval hint passed to data sources */ readonly #defaultUpdateInterval: number; + /** Optional callback for first init/fetch MetaMetrics (duration). */ + readonly #trackMetaMetricsEvent?: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; + + /** Whether we have already reported first init fetch for this session (reset on #stop). */ + #firstInitFetchReported = false; + readonly #controllerMutex = new Mutex(); /** @@ -455,6 +489,7 @@ export class AssetsController extends BaseController< subscribeToBasicFunctionalityChange, queryApiClient, rpcDataSourceConfig, + trackMetaMetricsEvent, }: AssetsControllerOptions) { super({ name: CONTROLLER_NAME, @@ -469,7 +504,7 @@ export class AssetsController extends BaseController< this.#isEnabled = isEnabled(); this.#isBasicFunctionality = isBasicFunctionality ?? ((): boolean => true); this.#defaultUpdateInterval = defaultUpdateInterval; - + this.#trackMetaMetricsEvent = trackMetaMetricsEvent; const rpcConfig = rpcDataSourceConfig ?? {}; const onActiveChainsUpdated = ( @@ -691,18 +726,44 @@ export class AssetsController extends BaseController< /** * Execute middlewares with request/response context. + * Returns response and exclusive duration per source (sum ≈ wall time). * - * @param middlewares - Middlewares to execute in order. + * @param sources - Data sources or middlewares with getName() and assetsMiddleware (executed in order). * @param request - The data request. * @param initialResponse - Optional initial response (for enriching existing data). - * @returns The final DataResponse after all middlewares have processed. + * @returns Response and durationByDataSource (exclusive ms per source name). */ async #executeMiddlewares( - middlewares: Middleware[], + sources: { getName(): string; assetsMiddleware: Middleware }[], request: DataRequest, initialResponse: DataResponse = {}, - ): Promise { - const chain = middlewares.reduceRight( + ): Promise<{ + response: DataResponse; + durationByDataSource: Record; + }> { + const names = sources.map((source) => source.getName()); + const middlewares = sources.map((source) => source.assetsMiddleware); + const inclusive: number[] = []; + const wrapped = middlewares.map( + (middleware, i) => + (async ( + ctx: FetchContext, + next: FetchNextFunction, + ): Promise<{ + request: DataRequest; + response: DataResponse; + getAssetsState: () => AssetsControllerStateInternal; + }> => { + const start = Date.now(); + try { + return await middleware(ctx, next); + } finally { + inclusive[i] = Date.now() - start; + } + }) as Middleware, + ); + + const chain = wrapped.reduceRight( (next, middleware) => async ( ctx, @@ -726,7 +787,17 @@ export class AssetsController extends BaseController< response: initialResponse, getAssetsState: () => this.state as AssetsControllerStateInternal, }); - return result.response; + + const durationByDataSource: Record = {}; + for (let i = 0; i < inclusive.length; i++) { + const nextInc = i + 1 < inclusive.length ? (inclusive[i + 1] ?? 0) : 0; + const exclusive = Math.max(0, (inclusive[i] ?? 0) - nextInc); + const name = names[i]; + if (name !== undefined) { + durationByDataSource[name] = exclusive; + } + } + return { response: result.response, durationByDataSource }; } // ============================================================================ @@ -754,27 +825,37 @@ export class AssetsController extends BaseController< } if (options?.forceUpdate) { + const startTime = Date.now(); const request = this.#buildDataRequest(accounts, chainIds, { assetTypes, dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, forceUpdate: true, }); - const middlewares = this.#isBasicFunctionality() + const sources = this.#isBasicFunctionality() ? [ - this.#accountsApiDataSource.assetsMiddleware, - this.#snapDataSource.assetsMiddleware, - this.#rpcDataSource.assetsMiddleware, - this.#detectionMiddleware.assetsMiddleware, - this.#tokenDataSource.assetsMiddleware, - this.#priceDataSource.assetsMiddleware, + this.#accountsApiDataSource, + this.#snapDataSource, + this.#rpcDataSource, + this.#detectionMiddleware, + this.#tokenDataSource, + this.#priceDataSource, ] - : [ - this.#rpcDataSource.assetsMiddleware, - this.#detectionMiddleware.assetsMiddleware, - ]; - const response = await this.#executeMiddlewares(middlewares, request); + : [this.#rpcDataSource, this.#detectionMiddleware]; + const { response, durationByDataSource } = await this.#executeMiddlewares( + sources, + request, + ); await this.#updateState(response); + if (this.#trackMetaMetricsEvent && !this.#firstInitFetchReported) { + this.#firstInitFetchReported = true; + const durationMs = Date.now() - startTime; + this.#trackMetaMetricsEvent({ + durationMs, + chainIds, + durationByDataSource, + }); + } } return this.#getAssetsFromState(accounts, chainIds, assetTypes); @@ -1325,14 +1406,12 @@ export class AssetsController extends BaseController< }); this.#subscribeAssets(); - if (this.#selectedAccounts.length > 0) { - this.getAssets(this.#selectedAccounts, { - chainIds: [...this.#enabledChains], - forceUpdate: true, - }).catch((error) => { - log('Failed to fetch assets', error); - }); - } + this.getAssets(this.#selectedAccounts, { + chainIds: [...this.#enabledChains], + forceUpdate: true, + }).catch((error) => { + log('Failed to fetch assets', error); + }); } /** @@ -1345,6 +1424,8 @@ export class AssetsController extends BaseController< hasPriceSubscription: this.#activeSubscriptions.has('ds:PriceDataSource'), }); + this.#firstInitFetchReported = false; + // Stop price subscription first (uses direct messenger call) this.unsubscribeAssetsPrice(); @@ -1716,12 +1797,8 @@ export class AssetsController extends BaseController< // Run through enrichment middlewares (Event Stack: Detection → Token → Price) // Include 'metadata' in dataTypes so TokenDataSource runs to enrich detected assets - const enrichedResponse = await this.#executeMiddlewares( - [ - this.#detectionMiddleware.assetsMiddleware, - this.#tokenDataSource.assetsMiddleware, - this.#priceDataSource.assetsMiddleware, - ], + const { response: enrichedResponse } = await this.#executeMiddlewares( + [this.#detectionMiddleware, this.#tokenDataSource, this.#priceDataSource], request ?? { accountsWithSupportedChains: [], chainIds: [], diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index 47a87ac46ce..920da5f61a0 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -102,6 +102,10 @@ function isValidMarketData(data: unknown): data is SpotPriceMarketData { export class PriceDataSource { readonly name = CONTROLLER_NAME; + getName(): string { + return this.name; + } + readonly #currency: SupportedCurrency; readonly #pollInterval: number; diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index eb98fbb0417..198b183bde1 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -109,6 +109,10 @@ function transformV3AssetResponseToMetadata( export class TokenDataSource { readonly name = CONTROLLER_NAME; + getName(): string { + return this.name; + } + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 4818b9b75e0..2a902f0b18b 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -9,6 +9,7 @@ export type { AssetsControllerState, AssetsControllerMessenger, AssetsControllerOptions, + AssetsControllerFirstInitFetchMetaMetricsPayload, AssetsControllerGetStateAction, AssetsControllerActions, AssetsControllerStateChangeEvent, diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts index a759e29fa2e..af0b08122af 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts @@ -32,6 +32,10 @@ createModuleLogger(projectLogger, CONTROLLER_NAME); export class DetectionMiddleware { readonly name = CONTROLLER_NAME; + getName(): string { + return this.name; + } + /** * Get the middleware for detecting assets without metadata. *