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
6 changes: 6 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Nest AssetsController Sentry spans (`AssetsDataSourceTiming`, `AssetsDataSourceError`, `AssetsControllerFirstInitFetch`, pipeline summaries) as subspans under parent `AssetsFullFetch` / `AssetsUpdatePipeline` / `AssetsBackgroundFetch` traces, and emit a single `AggregatedBalanceSelector` span for `calculateBalanceForAllWallets` instead of one root span per account group, to avoid Sentry rate limits ([#9672](https://github.com/MetaMask/core/pull/9672))
- Pipeline / data-source / update enrichment spans emit only on the unlock (first-init) fetch per session; later polls, force updates, and subscription enrichment skip tracing.
- Tracing helpers live in `utils/trace.ts` (`emitTrace` / `withTrace`); omit `trace` to no-op so call sites stay free of gating `if`s. Fast and background fetch lanes are sibling `withTrace` calls (not nested).
- Dashboard-facing spans (`AssetsFullFetch`, `AssetsUpdatePipeline`, `AggregatedBalanceSelector`, etc.) record `duration_ms` as a Sentry measurement (and backdate `startTime`) so Spans widgets charting `p95(duration_ms)` receive data. Nesting parents use `AssetsFetchPipeline` / `AssetsUpdateEnrichment` / `AssetsBackgroundFetch` / `AggregatedBalance`.
- `AggregatedBalanceSelector` is nested under an `AggregatedBalance` parent span via `parentContext`.
- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676))
- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^11.0.2` ([#9676](https://github.com/MetaMask/core/pull/9676))
- Bump `@metamask/keyring-snap-client` from `^9.2.0` to `^9.2.1` ([#9676](https://github.com/MetaMask/core/pull/9676))

### Fixed

- `withTrace` treats a rejected parent `trace` promise as best-effort (like `emitTrace`), so Sentry/adapter failures cannot fail full fetches or `handleAssetsUpdate` enrichment ([#9672](https://github.com/MetaMask/core/pull/9672))
- `SnapDataSource` now delivers snap-sourced balance updates directly to `AssetsController` via a constructor-supplied `onAssetsUpdate` callback instead of fanning out to `activeSubscriptions`, so updates (e.g. Tron energy/bandwidth) are no longer dropped when no active subscription is tracked for the chain in the SnapDataSource ([#9656](https://github.com/MetaMask/core/pull/9656))

## [11.2.1]
Expand Down
164 changes: 159 additions & 5 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,54 @@ describe('AssetsController', () => {
});
});

it('does not fail forceUpdate when parent trace rejects after work completes', async () => {
const traceMock = jest
.fn()
.mockImplementation(
async (
_request: TraceRequest,
fn?: (context?: unknown) => unknown,
) => {
if (fn) {
await fn({ id: 'parent' });
// Simulate Sentry/adapter failure after the span callback finishes.
throw new Error('telemetry failed');
}
return undefined;
},
);
const trace = traceMock as unknown as TraceCallback;

await withController(
{ controllerOptions: { trace } },
async ({ controller }) => {
expect(
await controller.getAssets([createMockInternalAccount()], {
forceUpdate: true,
}),
).toBeDefined();
},
);
});

it('still runs forceUpdate when parent trace rejects before invoking work', async () => {
const traceMock = jest
.fn()
.mockRejectedValue(new Error('telemetry failed'));
const trace = traceMock as unknown as TraceCallback;

await withController(
{ controllerOptions: { trace } },
async ({ controller }) => {
expect(
await controller.getAssets([createMockInternalAccount()], {
forceUpdate: true,
}),
).toBeDefined();
},
);
});

// Endpoint selection from the flag is unit-tested in AccountsApiDataSource;
// this asserts the controller wires its messenger through so the
// `assetsAccountsApiV6` flag drives endpoint selection end-to-end.
Expand Down Expand Up @@ -1395,6 +1443,46 @@ describe('AssetsController', () => {
});

describe('handleAssetsUpdate', () => {
it('does not fail when parent trace rejects after enrichment completes', async () => {
const traceMock = jest
.fn()
.mockImplementation(
async (
_request: TraceRequest,
fn?: (context?: unknown) => unknown,
) => {
if (fn) {
await fn({ id: 'parent' });
throw new Error('telemetry failed');
}
return undefined;
},
);
const trace = traceMock as unknown as TraceCallback;

await withController(
{ controllerOptions: { trace } },
async ({ controller }) => {
expect(
await controller.handleAssetsUpdate(
{
updateMode: 'merge',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '1' },
},
},
},
'test-source',
),
).toBeUndefined();
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID],
).toStrictEqual({ amount: '1' });
},
);
});

it('preserves existing rich metadata when the API response has empty symbol and name', async () => {
const richMetadata: FungibleAssetMetadata = {
type: 'erc20',
Expand Down Expand Up @@ -2481,11 +2569,22 @@ describe('AssetsController', () => {
});

it('invokes trace with first init fetch trace request on unlock', async () => {
const parentSpan = { id: 'assets-full-fetch' };
const traceMock = jest
.fn()
.mockImplementation(
async (_request: TraceRequest, fn?: (context?: unknown) => unknown) =>
fn?.(),
async (
request: TraceRequest,
fn?: (context?: unknown) => unknown,
) => {
if (fn) {
// Parent spans (with callback work) provide context for nesting.
return fn(
request.parentContext === undefined ? parentSpan : undefined,
);
}
return undefined;
},
);
const trace = traceMock as unknown as TraceCallback;

Expand Down Expand Up @@ -2519,7 +2618,12 @@ describe('AssetsController', () => {
duration_ms: expect.any(Number),
chain_ids: expect.any(String),
}),
tags: { controller: 'AssetsController' },
tags: expect.objectContaining({
controller: 'AssetsController',
duration_ms: expect.any(Number),
}),
startTime: expect.any(Number),
parentContext: parentSpan,
});
const {
duration_ms: durationMs,
Expand All @@ -2529,6 +2633,32 @@ describe('AssetsController', () => {
expect(durationMs).toBeGreaterThanOrEqual(0);
expect(typeof chainIds).toBe('string');
expect(typeof durationByDataSource).toBe('object');

const fullFetchCalls = traceMock.mock.calls.filter(
(call) => (call[0] as TraceRequest).name === 'AssetsFullFetch',
);
expect(fullFetchCalls.length).toBeGreaterThan(0);
expect(fullFetchCalls[0][0]).toMatchObject({
data: expect.objectContaining({
duration_ms: expect.any(Number),
}),
tags: expect.objectContaining({
duration_ms: expect.any(Number),
}),
startTime: expect.any(Number),
parentContext: parentSpan,
});

const timingCalls = traceMock.mock.calls.filter(
(call) =>
(call[0] as TraceRequest).name === 'AssetsDataSourceTiming',
);
expect(timingCalls.length).toBeGreaterThan(0);
for (const [timingRequest] of timingCalls) {
expect(timingRequest).toMatchObject({
parentContext: parentSpan,
});
}
},
);
});
Expand Down Expand Up @@ -2591,7 +2721,7 @@ describe('AssetsController', () => {
);
});

it('invokes trace only once per session until lock', async () => {
it('invokes first-init fetch trace only once per session until lock', async () => {
const traceMock = jest
.fn()
.mockImplementation(
Expand All @@ -2605,7 +2735,7 @@ describe('AssetsController', () => {
clientControllerState: { isUiOpen: true },
controllerOptions: { trace },
},
async ({ messenger }) => {
async ({ controller, messenger }) => {
// UI must be open and keyring unlocked for asset tracking to run
(
messenger as unknown as {
Expand All @@ -2624,6 +2754,30 @@ describe('AssetsController', () => {
'AssetsControllerFirstInitFetch',
);
expect(firstInitFetchCalls).toHaveLength(1);

const fullFetchCallsBefore = traceMock.mock.calls.filter(
(call) => (call[0] as TraceRequest).name === 'AssetsFullFetch',
).length;
const timingCallsBefore = traceMock.mock.calls.filter(
(call) =>
(call[0] as TraceRequest).name === 'AssetsDataSourceTiming',
).length;

// Later force updates must not emit pipeline spans (unlock-only tracing).
await controller.getAssets([createMockInternalAccount()], {
forceUpdate: true,
});

const fullFetchCallsAfter = traceMock.mock.calls.filter(
(call) => (call[0] as TraceRequest).name === 'AssetsFullFetch',
).length;
const timingCallsAfter = traceMock.mock.calls.filter(
(call) =>
(call[0] as TraceRequest).name === 'AssetsDataSourceTiming',
).length;

expect(fullFetchCallsAfter).toBe(fullFetchCallsBefore);
expect(timingCallsAfter).toBe(timingCallsBefore);
},
);
});
Expand Down
Loading