diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index f625b06fecd..394dca12a06 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Sentry traces for Assets Health dashboard ([#8310](https://github.com/MetaMask/core/pull/8310)) + - `AssetsDataSourceTiming` — per-source latency for each middleware in the fetch pipeline + - `AssetsDataSourceError` — tracks middleware failures with source names and error counts + - `AssetsFullFetch` — end-to-end fetch timing with asset/price/chain/account counts + - `AssetsUpdatePipeline` — enrichment pipeline timing for pushed data source updates + - `AssetsSubscriptionError` — subscription failure tracking per data source + - `AssetsStateSize` — entry counts for balances, metadata, prices, custom assets, unique assets, and network count (once on app start) + - `AggregatedBalanceSelector` — balance selector computation time with asset/network/account counts + - Add optional `trace` parameter to `getAggregatedBalanceForAccount` selector + ### Changed - Bump `@metamask/transaction-controller` from `^63.1.0` to `^63.2.0` ([#8301](https://github.com/MetaMask/core/pull/8301)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index bfcee994d8f..23cd788cb4d 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1222,10 +1222,15 @@ describe('AssetsController', () => { // Allow #start() -> getAssets() to resolve so the callback runs await new Promise((resolve) => setTimeout(resolve, 100)); - expect(traceMock).toHaveBeenCalledTimes(1); - const [request] = traceMock.mock.calls[0]; + const firstInitFetchCalls = traceMock.mock.calls.filter( + (call) => + (call[0] as TraceRequest).name === + 'AssetsControllerFirstInitFetch', + ); + expect(firstInitFetchCalls).toHaveLength(1); + const [request] = firstInitFetchCalls[0]; expect(request).toMatchObject({ - name: 'AssetsController First Init Fetch', + name: 'AssetsControllerFirstInitFetch', data: expect.objectContaining({ duration_ms: expect.any(Number), chain_ids: expect.any(String), @@ -1271,7 +1276,12 @@ describe('AssetsController', () => { messenger.publish('KeyringController:unlock'); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(traceMock).toHaveBeenCalledTimes(1); + const firstInitFetchCalls = traceMock.mock.calls.filter( + (call) => + (call[0] as TraceRequest).name === + 'AssetsControllerFirstInitFetch', + ); + expect(firstInitFetchCalls).toHaveLength(1); }, ); }); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7a8164c8a22..2a32590437c 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -159,6 +159,17 @@ const MESSENGER_EXPOSED_METHODS = [ /** Default polling interval hint for data sources (30 seconds) */ const DEFAULT_POLLING_INTERVAL_MS = 30_000; +// ============================================================================ +// TRACE NAMES — used in Sentry spans (search these strings in Discover) +// ============================================================================ +const TRACE_FIRST_INIT_FETCH = 'AssetsControllerFirstInitFetch'; +const TRACE_FULL_FETCH = 'AssetsFullFetch'; +const TRACE_DATA_SOURCE_TIMING = 'AssetsDataSourceTiming'; +const TRACE_DATA_SOURCE_ERROR = 'AssetsDataSourceError'; +const TRACE_UPDATE_PIPELINE = 'AssetsUpdatePipeline'; +const TRACE_SUBSCRIPTION_ERROR = 'AssetsSubscriptionError'; +const TRACE_STATE_SIZE = 'AssetsStateSize'; + const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // ============================================================================ @@ -509,6 +520,83 @@ export class AssetsController extends BaseController< /** Whether we have already reported first init fetch for this session (reset on #stop). */ #firstInitFetchReported = false; + /** Whether we have already reported state size for this session (reset on #stop). */ + #stateSizeReported = false; + + /** + * Fire-and-forget trace helper. Swallows errors so telemetry never breaks the controller. + * + * @param name - Trace / span name visible in Sentry. + * @param data - Key-value pairs attached as span data. + * @param tags - Key-value pairs used for Sentry filtering. + */ + #emitTrace( + name: string, + data: Record, + tags: Record = { + controller: 'AssetsController', + }, + ): void { + if (!this.#trace) { + return; + } + this.#trace({ name, data, tags }, () => undefined).catch(() => { + // Telemetry failure must not break. + }); + } + + /** + * Emit a state-size trace once on app start (first state update after unlock). + */ + #emitStateSizeTrace(): void { + if (!this.#trace || this.#stateSizeReported) { + return; + } + this.#stateSizeReported = true; + + const { + assetsBalance: balances, + customAssets, + assetsInfo, + assetsPrice, + } = this.state; + + // Count balance entries and collect unique asset IDs / chain IDs in one pass. + let balanceEntries = 0; + const uniqueAssets = new Set(); + const uniqueNetworks = new Set(); + + for (const acct of Object.values(balances)) { + const assetIds = Object.keys(acct); + balanceEntries += assetIds.length; + for (const assetId of assetIds) { + uniqueAssets.add(assetId); + // CAIP-19 format: "eip155:1/slip44:60" — chainId is everything before "/" + const slash = assetId.indexOf('/'); + if (slash > 0) { + uniqueNetworks.add(assetId.slice(0, slash)); + } + } + } + + let customAssetEntries = 0; + for (const ids of Object.values(customAssets)) { + if (Array.isArray(ids)) { + customAssetEntries += ids.length; + } + } + + this.#emitTrace(TRACE_STATE_SIZE, { + balance_entries: balanceEntries, + balance_accounts: Object.keys(balances).length, + unique_asset_count: uniqueAssets.size, + network_count: uniqueNetworks.size, + metadata_entries: Object.keys(assetsInfo).length, + price_entries: Object.keys(assetsPrice).length, + custom_asset_entries: customAssetEntries, + }); + } + /** Whether the client (UI) is open. Combined with #keyringUnlocked for #updateActive. */ #uiOpen = false; @@ -925,17 +1013,18 @@ export class AssetsController extends BaseController< response: DataResponse; getAssetsState: () => AssetsControllerStateInternal; }> => { - const start = Date.now(); + const start = performance.now(); try { return await middleware(ctx, next); } finally { - inclusive[i] = Date.now() - start; + inclusive[i] = performance.now() - start; } }) as Middleware, ); + const middlewareErrors: string[] = []; const chain = wrapped.reduceRight( - (next, middleware) => + (next, middleware, index) => async ( ctx, ): Promise<{ @@ -946,6 +1035,8 @@ export class AssetsController extends BaseController< try { return await middleware(ctx, next); } catch (error) { + const sourceName = names[index] ?? `middleware_${index}`; + middlewareErrors.push(sourceName); console.error('[AssetsController] Middleware failed:', error); return next(ctx); } @@ -973,6 +1064,28 @@ export class AssetsController extends BaseController< durationByDataSource[key] = ms; } } + + // Emit per-source timing traces for the Assets Health dashboard + for (const [sourceName, durationMs] of Object.entries( + durationByDataSource, + )) { + this.#emitTrace(TRACE_DATA_SOURCE_TIMING, { + source: sourceName, + duration_ms: durationMs, + chain_count: request.chainIds.length, + account_count: request.accountsWithSupportedChains.length, + }); + } + + // Emit error traces for failed middlewares + if (middlewareErrors.length > 0) { + this.#emitTrace(TRACE_DATA_SOURCE_ERROR, { + failed_sources: middlewareErrors.join(','), + error_count: middlewareErrors.length, + chain_count: request.chainIds.length, + }); + } + return { response: result.response, durationByDataSource }; } @@ -1008,7 +1121,7 @@ export class AssetsController extends BaseController< } if (options?.forceUpdate) { - const startTime = Date.now(); + const startTime = performance.now(); const request = this.#buildDataRequest(accounts, chainIds, { assetTypes, dataTypes, @@ -1047,25 +1160,34 @@ export class AssetsController extends BaseController< const updateMode = options?.updateMode ?? (isPartialChainFetch ? 'merge' : 'full'); await this.#updateState({ ...response, updateMode }); - if (this.#trace && !this.#firstInitFetchReported) { + + const durationMs = performance.now() - startTime; + + // Emit trace for every full fetch (Assets Health dashboard) + this.#emitTrace(TRACE_FULL_FETCH, { + duration_ms: durationMs, + chain_count: chainIds.length, + account_count: accounts.length, + basic_functionality: this.#isBasicFunctionality(), + asset_count: response.assetsBalance + ? Object.values(response.assetsBalance).reduce( + (sum, acct) => sum + Object.keys(acct).length, + 0, + ) + : 0, + price_count: response.assetsPrice + ? Object.keys(response.assetsPrice).length + : 0, + ...durationByDataSource, + }); + + if (!this.#firstInitFetchReported) { this.#firstInitFetchReported = true; - const durationMs = Date.now() - startTime; - try { - await this.#trace( - { - name: 'AssetsController First Init Fetch', - data: { - duration_ms: durationMs, - chain_ids: JSON.stringify(chainIds), - ...durationByDataSource, - }, - tags: { controller: 'AssetsController' }, - }, - () => undefined, - ); - } catch { - // Telemetry failure must not break. - } + this.#emitTrace(TRACE_FIRST_INIT_FETCH, { + duration_ms: durationMs, + chain_ids: JSON.stringify(chainIds), + ...durationByDataSource, + }); } } @@ -1669,6 +1791,9 @@ export class AssetsController extends BaseController< } }); + // Emit state size trace (throttled to avoid JSON.stringify on every update) + this.#emitStateSizeTrace(); + // Calculate changed prices const changedPriceAssets: string[] = normalizedResponse.assetsPrice ? Object.keys(normalizedResponse.assetsPrice).filter( @@ -1879,6 +2004,7 @@ export class AssetsController extends BaseController< }); this.#firstInitFetchReported = false; + this.#stateSizeReported = false; // Stop price subscription first (uses direct messenger call) this.unsubscribeAssetsPrice(); @@ -2099,6 +2225,10 @@ export class AssetsController extends BaseController< `[AssetsController] Failed to subscribe to '${sourceId}':`, error, ); + this.#emitTrace(TRACE_SUBSCRIPTION_ERROR, { + source: sourceId, + error_message: String(error), + }); }); // Track subscription @@ -2293,6 +2423,7 @@ export class AssetsController extends BaseController< sourceId: string, request?: DataRequest, ): Promise { + const updateStart = performance.now(); log('Assets updated from data source', { sourceId, hasBalance: Boolean(response.assetsBalance), @@ -2318,6 +2449,17 @@ export class AssetsController extends BaseController< ); await this.#updateState(enrichedResponse); + + this.#emitTrace(TRACE_UPDATE_PIPELINE, { + source: sourceId, + duration_ms: performance.now() - updateStart, + has_balance: Boolean(response.assetsBalance), + has_price: Boolean(response.assetsPrice), + has_metadata: Boolean(enrichedResponse.assetsInfo), + balance_account_count: response.assetsBalance + ? Object.keys(response.assetsBalance).length + : 0, + }); } // ============================================================================ diff --git a/packages/assets-controller/src/selectors/balance.ts b/packages/assets-controller/src/selectors/balance.ts index ed0933c0e07..1e686e5c75c 100644 --- a/packages/assets-controller/src/selectors/balance.ts +++ b/packages/assets-controller/src/selectors/balance.ts @@ -1,5 +1,6 @@ import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; import { toHex } from '@metamask/controller-utils'; +import type { TraceCallback } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { CaipChainId, Hex } from '@metamask/utils'; import { @@ -19,6 +20,11 @@ import type { Caip19AssetId, } from '../types'; +// ============================================================================ +// TRACE NAMES — used in Sentry spans (search these strings in Discover) +// ============================================================================ +const TRACE_AGGREGATED_BALANCE_SELECTOR = 'AggregatedBalanceSelector'; + export type EnabledNetworkMap = | Record> | undefined; @@ -394,7 +400,9 @@ export function getAggregatedBalanceForAccount( accountTreeState?: AccountTreeControllerState, internalAccountsOrAccountIds?: InternalAccount[] | AccountId[], accountsById?: AccountsById, + trace?: TraceCallback, ): AggregatedBalanceForAccount { + const startTime = trace ? performance.now() : 0; const { assetsBalance, assetsInfo, assetPreferences, assetsPrice } = state; const metadata = (assetsInfo ?? {}) as Record; @@ -468,6 +476,30 @@ export function getAggregatedBalanceForAccount( } } + if (trace) { + const durationMs = performance.now() - startTime; + const uniqueNetworks = new Set(); + for (const assetId of merged.keys()) { + const info = getAssetInfo(assetInfoCache, assetId); + uniqueNetworks.add(info.chainId); + } + trace( + { + name: TRACE_AGGREGATED_BALANCE_SELECTOR, + data: { + duration_ms: durationMs, + asset_count: merged.size, + network_count: uniqueNetworks.size, + account_count: accountsToAggregate.length, + }, + tags: { controller: 'AssetsController' }, + }, + () => undefined, + ).catch(() => { + // Telemetry failure must not break. + }); + } + if (hasPrices) { const pricePercentChange1d = totalBalanceInFiat > 0 ? weightedNumerator / totalBalanceInFiat : 0;