Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 14 additions & 4 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
},
);
});
Expand Down
186 changes: 164 additions & 22 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

// ============================================================================
Expand Down Expand Up @@ -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<string, number | string | boolean>,
tags: Record<string, number | string | boolean> = {
controller: 'AssetsController',
},
): void {
if (!this.#trace) {
return;
}
this.#trace({ name, data, tags }, () => undefined).catch(() => {
// Telemetry failure must not break.
Comment thread
salimtb marked this conversation as resolved.
});
}

/**
* 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<string>();
const uniqueNetworks = new Set<string>();

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;

Expand Down Expand Up @@ -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<NextFunction>(
(next, middleware) =>
(next, middleware, index) =>
async (
ctx,
): Promise<{
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2293,6 +2423,7 @@ export class AssetsController extends BaseController<
sourceId: string,
request?: DataRequest,
): Promise<void> {
const updateStart = performance.now();
log('Assets updated from data source', {
sourceId,
hasBalance: Boolean(response.assetsBalance),
Expand All @@ -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,
});
}

// ============================================================================
Expand Down
32 changes: 32 additions & 0 deletions packages/assets-controller/src/selectors/balance.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string, Record<string, boolean>>
| undefined;
Expand Down Expand Up @@ -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<Caip19AssetId, AssetMetadata>;
Expand Down Expand Up @@ -468,6 +476,30 @@ export function getAggregatedBalanceForAccount(
}
}

if (trace) {
const durationMs = performance.now() - startTime;
const uniqueNetworks = new Set<CaipChainId>();
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.
Comment thread
salimtb marked this conversation as resolved.
});
}

if (hasPrices) {
const pricePercentChange1d =
totalBalanceInFiat > 0 ? weightedNumerator / totalBalanceInFiat : 0;
Expand Down
Loading