Skip to content

Commit 9f69185

Browse files
authored
feat: add Sentry traces for Assets Health dashboard (#8310)
Add comprehensive tracing across AssetsController and balance selector to power an Assets Health dashboard in Sentry. Traces cover data source timing, errors, full fetch pipeline, update pipeline, subscription failures, state size (once on app start), and aggregated balance selector performance. ## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily adds non-blocking telemetry (new Sentry trace emissions and timing measurements) with an optional selector parameter; low functional impact beyond minor timing/overhead changes. > > **Overview** > Adds comprehensive Sentry tracing across `AssetsController` to power an Assets Health dashboard, including per-middleware timings and failures, end-to-end fetch timing, subscription failure reporting, update/enrichment pipeline timing, and a once-per-session state size snapshot. > > Updates timing measurement to use `performance.now()`, centralizes trace emission via a safe `#emitTrace` helper, and adjusts tests for the renamed `AssetsControllerFirstInitFetch` span. Also adds an optional `trace` callback parameter to `getAggregatedBalanceForAccount` to measure selector compute time and include asset/network/account counts. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8427f87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8c9574c commit 9f69185

4 files changed

Lines changed: 222 additions & 26 deletions

File tree

packages/assets-controller/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add Sentry traces for Assets Health dashboard ([#8310](https://github.com/MetaMask/core/pull/8310))
13+
- `AssetsDataSourceTiming` — per-source latency for each middleware in the fetch pipeline
14+
- `AssetsDataSourceError` — tracks middleware failures with source names and error counts
15+
- `AssetsFullFetch` — end-to-end fetch timing with asset/price/chain/account counts
16+
- `AssetsUpdatePipeline` — enrichment pipeline timing for pushed data source updates
17+
- `AssetsSubscriptionError` — subscription failure tracking per data source
18+
- `AssetsStateSize` — entry counts for balances, metadata, prices, custom assets, unique assets, and network count (once on app start)
19+
- `AggregatedBalanceSelector` — balance selector computation time with asset/network/account counts
20+
- Add optional `trace` parameter to `getAggregatedBalanceForAccount` selector
21+
1022
### Changed
1123

1224
- Bump `@metamask/transaction-controller` from `^63.1.0` to `^63.3.0` ([#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313))

packages/assets-controller/src/AssetsController.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,10 +1222,15 @@ describe('AssetsController', () => {
12221222
// Allow #start() -> getAssets() to resolve so the callback runs
12231223
await new Promise((resolve) => setTimeout(resolve, 100));
12241224

1225-
expect(traceMock).toHaveBeenCalledTimes(1);
1226-
const [request] = traceMock.mock.calls[0];
1225+
const firstInitFetchCalls = traceMock.mock.calls.filter(
1226+
(call) =>
1227+
(call[0] as TraceRequest).name ===
1228+
'AssetsControllerFirstInitFetch',
1229+
);
1230+
expect(firstInitFetchCalls).toHaveLength(1);
1231+
const [request] = firstInitFetchCalls[0];
12271232
expect(request).toMatchObject({
1228-
name: 'AssetsController First Init Fetch',
1233+
name: 'AssetsControllerFirstInitFetch',
12291234
data: expect.objectContaining({
12301235
duration_ms: expect.any(Number),
12311236
chain_ids: expect.any(String),
@@ -1271,7 +1276,12 @@ describe('AssetsController', () => {
12711276
messenger.publish('KeyringController:unlock');
12721277
await new Promise((resolve) => setTimeout(resolve, 100));
12731278

1274-
expect(traceMock).toHaveBeenCalledTimes(1);
1279+
const firstInitFetchCalls = traceMock.mock.calls.filter(
1280+
(call) =>
1281+
(call[0] as TraceRequest).name ===
1282+
'AssetsControllerFirstInitFetch',
1283+
);
1284+
expect(firstInitFetchCalls).toHaveLength(1);
12751285
},
12761286
);
12771287
});

packages/assets-controller/src/AssetsController.ts

Lines changed: 164 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ const MESSENGER_EXPOSED_METHODS = [
159159
/** Default polling interval hint for data sources (30 seconds) */
160160
const DEFAULT_POLLING_INTERVAL_MS = 30_000;
161161

162+
// ============================================================================
163+
// TRACE NAMES — used in Sentry spans (search these strings in Discover)
164+
// ============================================================================
165+
const TRACE_FIRST_INIT_FETCH = 'AssetsControllerFirstInitFetch';
166+
const TRACE_FULL_FETCH = 'AssetsFullFetch';
167+
const TRACE_DATA_SOURCE_TIMING = 'AssetsDataSourceTiming';
168+
const TRACE_DATA_SOURCE_ERROR = 'AssetsDataSourceError';
169+
const TRACE_UPDATE_PIPELINE = 'AssetsUpdatePipeline';
170+
const TRACE_SUBSCRIPTION_ERROR = 'AssetsSubscriptionError';
171+
const TRACE_STATE_SIZE = 'AssetsStateSize';
172+
162173
const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
163174

164175
// ============================================================================
@@ -509,6 +520,83 @@ export class AssetsController extends BaseController<
509520
/** Whether we have already reported first init fetch for this session (reset on #stop). */
510521
#firstInitFetchReported = false;
511522

523+
/** Whether we have already reported state size for this session (reset on #stop). */
524+
#stateSizeReported = false;
525+
526+
/**
527+
* Fire-and-forget trace helper. Swallows errors so telemetry never breaks the controller.
528+
*
529+
* @param name - Trace / span name visible in Sentry.
530+
* @param data - Key-value pairs attached as span data.
531+
* @param tags - Key-value pairs used for Sentry filtering.
532+
*/
533+
#emitTrace(
534+
name: string,
535+
data: Record<string, number | string | boolean>,
536+
tags: Record<string, number | string | boolean> = {
537+
controller: 'AssetsController',
538+
},
539+
): void {
540+
if (!this.#trace) {
541+
return;
542+
}
543+
this.#trace({ name, data, tags }, () => undefined).catch(() => {
544+
// Telemetry failure must not break.
545+
});
546+
}
547+
548+
/**
549+
* Emit a state-size trace once on app start (first state update after unlock).
550+
*/
551+
#emitStateSizeTrace(): void {
552+
if (!this.#trace || this.#stateSizeReported) {
553+
return;
554+
}
555+
this.#stateSizeReported = true;
556+
557+
const {
558+
assetsBalance: balances,
559+
customAssets,
560+
assetsInfo,
561+
assetsPrice,
562+
} = this.state;
563+
564+
// Count balance entries and collect unique asset IDs / chain IDs in one pass.
565+
let balanceEntries = 0;
566+
const uniqueAssets = new Set<string>();
567+
const uniqueNetworks = new Set<string>();
568+
569+
for (const acct of Object.values(balances)) {
570+
const assetIds = Object.keys(acct);
571+
balanceEntries += assetIds.length;
572+
for (const assetId of assetIds) {
573+
uniqueAssets.add(assetId);
574+
// CAIP-19 format: "eip155:1/slip44:60" — chainId is everything before "/"
575+
const slash = assetId.indexOf('/');
576+
if (slash > 0) {
577+
uniqueNetworks.add(assetId.slice(0, slash));
578+
}
579+
}
580+
}
581+
582+
let customAssetEntries = 0;
583+
for (const ids of Object.values(customAssets)) {
584+
if (Array.isArray(ids)) {
585+
customAssetEntries += ids.length;
586+
}
587+
}
588+
589+
this.#emitTrace(TRACE_STATE_SIZE, {
590+
balance_entries: balanceEntries,
591+
balance_accounts: Object.keys(balances).length,
592+
unique_asset_count: uniqueAssets.size,
593+
network_count: uniqueNetworks.size,
594+
metadata_entries: Object.keys(assetsInfo).length,
595+
price_entries: Object.keys(assetsPrice).length,
596+
custom_asset_entries: customAssetEntries,
597+
});
598+
}
599+
512600
/** Whether the client (UI) is open. Combined with #keyringUnlocked for #updateActive. */
513601
#uiOpen = false;
514602

@@ -925,17 +1013,18 @@ export class AssetsController extends BaseController<
9251013
response: DataResponse;
9261014
getAssetsState: () => AssetsControllerStateInternal;
9271015
}> => {
928-
const start = Date.now();
1016+
const start = performance.now();
9291017
try {
9301018
return await middleware(ctx, next);
9311019
} finally {
932-
inclusive[i] = Date.now() - start;
1020+
inclusive[i] = performance.now() - start;
9331021
}
9341022
}) as Middleware,
9351023
);
9361024

1025+
const middlewareErrors: string[] = [];
9371026
const chain = wrapped.reduceRight<NextFunction>(
938-
(next, middleware) =>
1027+
(next, middleware, index) =>
9391028
async (
9401029
ctx,
9411030
): Promise<{
@@ -946,6 +1035,8 @@ export class AssetsController extends BaseController<
9461035
try {
9471036
return await middleware(ctx, next);
9481037
} catch (error) {
1038+
const sourceName = names[index] ?? `middleware_${index}`;
1039+
middlewareErrors.push(sourceName);
9491040
console.error('[AssetsController] Middleware failed:', error);
9501041
return next(ctx);
9511042
}
@@ -973,6 +1064,28 @@ export class AssetsController extends BaseController<
9731064
durationByDataSource[key] = ms;
9741065
}
9751066
}
1067+
1068+
// Emit per-source timing traces for the Assets Health dashboard
1069+
for (const [sourceName, durationMs] of Object.entries(
1070+
durationByDataSource,
1071+
)) {
1072+
this.#emitTrace(TRACE_DATA_SOURCE_TIMING, {
1073+
source: sourceName,
1074+
duration_ms: durationMs,
1075+
chain_count: request.chainIds.length,
1076+
account_count: request.accountsWithSupportedChains.length,
1077+
});
1078+
}
1079+
1080+
// Emit error traces for failed middlewares
1081+
if (middlewareErrors.length > 0) {
1082+
this.#emitTrace(TRACE_DATA_SOURCE_ERROR, {
1083+
failed_sources: middlewareErrors.join(','),
1084+
error_count: middlewareErrors.length,
1085+
chain_count: request.chainIds.length,
1086+
});
1087+
}
1088+
9761089
return { response: result.response, durationByDataSource };
9771090
}
9781091

@@ -1008,7 +1121,7 @@ export class AssetsController extends BaseController<
10081121
}
10091122

10101123
if (options?.forceUpdate) {
1011-
const startTime = Date.now();
1124+
const startTime = performance.now();
10121125
const request = this.#buildDataRequest(accounts, chainIds, {
10131126
assetTypes,
10141127
dataTypes,
@@ -1047,25 +1160,34 @@ export class AssetsController extends BaseController<
10471160
const updateMode =
10481161
options?.updateMode ?? (isPartialChainFetch ? 'merge' : 'full');
10491162
await this.#updateState({ ...response, updateMode });
1050-
if (this.#trace && !this.#firstInitFetchReported) {
1163+
1164+
const durationMs = performance.now() - startTime;
1165+
1166+
// Emit trace for every full fetch (Assets Health dashboard)
1167+
this.#emitTrace(TRACE_FULL_FETCH, {
1168+
duration_ms: durationMs,
1169+
chain_count: chainIds.length,
1170+
account_count: accounts.length,
1171+
basic_functionality: this.#isBasicFunctionality(),
1172+
asset_count: response.assetsBalance
1173+
? Object.values(response.assetsBalance).reduce(
1174+
(sum, acct) => sum + Object.keys(acct).length,
1175+
0,
1176+
)
1177+
: 0,
1178+
price_count: response.assetsPrice
1179+
? Object.keys(response.assetsPrice).length
1180+
: 0,
1181+
...durationByDataSource,
1182+
});
1183+
1184+
if (!this.#firstInitFetchReported) {
10511185
this.#firstInitFetchReported = true;
1052-
const durationMs = Date.now() - startTime;
1053-
try {
1054-
await this.#trace(
1055-
{
1056-
name: 'AssetsController First Init Fetch',
1057-
data: {
1058-
duration_ms: durationMs,
1059-
chain_ids: JSON.stringify(chainIds),
1060-
...durationByDataSource,
1061-
},
1062-
tags: { controller: 'AssetsController' },
1063-
},
1064-
() => undefined,
1065-
);
1066-
} catch {
1067-
// Telemetry failure must not break.
1068-
}
1186+
this.#emitTrace(TRACE_FIRST_INIT_FETCH, {
1187+
duration_ms: durationMs,
1188+
chain_ids: JSON.stringify(chainIds),
1189+
...durationByDataSource,
1190+
});
10691191
}
10701192
}
10711193

@@ -1669,6 +1791,9 @@ export class AssetsController extends BaseController<
16691791
}
16701792
});
16711793

1794+
// Emit state size trace (throttled to avoid JSON.stringify on every update)
1795+
this.#emitStateSizeTrace();
1796+
16721797
// Calculate changed prices
16731798
const changedPriceAssets: string[] = normalizedResponse.assetsPrice
16741799
? Object.keys(normalizedResponse.assetsPrice).filter(
@@ -1879,6 +2004,7 @@ export class AssetsController extends BaseController<
18792004
});
18802005

18812006
this.#firstInitFetchReported = false;
2007+
this.#stateSizeReported = false;
18822008

18832009
// Stop price subscription first (uses direct messenger call)
18842010
this.unsubscribeAssetsPrice();
@@ -2099,6 +2225,10 @@ export class AssetsController extends BaseController<
20992225
`[AssetsController] Failed to subscribe to '${sourceId}':`,
21002226
error,
21012227
);
2228+
this.#emitTrace(TRACE_SUBSCRIPTION_ERROR, {
2229+
source: sourceId,
2230+
error_message: String(error),
2231+
});
21022232
});
21032233

21042234
// Track subscription
@@ -2293,6 +2423,7 @@ export class AssetsController extends BaseController<
22932423
sourceId: string,
22942424
request?: DataRequest,
22952425
): Promise<void> {
2426+
const updateStart = performance.now();
22962427
log('Assets updated from data source', {
22972428
sourceId,
22982429
hasBalance: Boolean(response.assetsBalance),
@@ -2318,6 +2449,17 @@ export class AssetsController extends BaseController<
23182449
);
23192450

23202451
await this.#updateState(enrichedResponse);
2452+
2453+
this.#emitTrace(TRACE_UPDATE_PIPELINE, {
2454+
source: sourceId,
2455+
duration_ms: performance.now() - updateStart,
2456+
has_balance: Boolean(response.assetsBalance),
2457+
has_price: Boolean(response.assetsPrice),
2458+
has_metadata: Boolean(enrichedResponse.assetsInfo),
2459+
balance_account_count: response.assetsBalance
2460+
? Object.keys(response.assetsBalance).length
2461+
: 0,
2462+
});
23212463
}
23222464

23232465
// ============================================================================

packages/assets-controller/src/selectors/balance.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AccountTreeControllerState } from '@metamask/account-tree-controller';
22
import { toHex } from '@metamask/controller-utils';
3+
import type { TraceCallback } from '@metamask/controller-utils';
34
import type { InternalAccount } from '@metamask/keyring-internal-api';
45
import type { CaipChainId, Hex } from '@metamask/utils';
56
import {
@@ -19,6 +20,11 @@ import type {
1920
Caip19AssetId,
2021
} from '../types';
2122

23+
// ============================================================================
24+
// TRACE NAMES — used in Sentry spans (search these strings in Discover)
25+
// ============================================================================
26+
const TRACE_AGGREGATED_BALANCE_SELECTOR = 'AggregatedBalanceSelector';
27+
2228
export type EnabledNetworkMap =
2329
| Record<string, Record<string, boolean>>
2430
| undefined;
@@ -394,7 +400,9 @@ export function getAggregatedBalanceForAccount(
394400
accountTreeState?: AccountTreeControllerState,
395401
internalAccountsOrAccountIds?: InternalAccount[] | AccountId[],
396402
accountsById?: AccountsById,
403+
trace?: TraceCallback,
397404
): AggregatedBalanceForAccount {
405+
const startTime = trace ? performance.now() : 0;
398406
const { assetsBalance, assetsInfo, assetPreferences, assetsPrice } = state;
399407

400408
const metadata = (assetsInfo ?? {}) as Record<Caip19AssetId, AssetMetadata>;
@@ -468,6 +476,30 @@ export function getAggregatedBalanceForAccount(
468476
}
469477
}
470478

479+
if (trace) {
480+
const durationMs = performance.now() - startTime;
481+
const uniqueNetworks = new Set<CaipChainId>();
482+
for (const assetId of merged.keys()) {
483+
const info = getAssetInfo(assetInfoCache, assetId);
484+
uniqueNetworks.add(info.chainId);
485+
}
486+
trace(
487+
{
488+
name: TRACE_AGGREGATED_BALANCE_SELECTOR,
489+
data: {
490+
duration_ms: durationMs,
491+
asset_count: merged.size,
492+
network_count: uniqueNetworks.size,
493+
account_count: accountsToAggregate.length,
494+
},
495+
tags: { controller: 'AssetsController' },
496+
},
497+
() => undefined,
498+
).catch(() => {
499+
// Telemetry failure must not break.
500+
});
501+
}
502+
471503
if (hasPrices) {
472504
const pricePercentChange1d =
473505
totalBalanceInFiat > 0 ? weightedNumerator / totalBalanceInFiat : 0;

0 commit comments

Comments
 (0)