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

## [Unreleased]

### Added

- Add Sentry quote-fetch and provider first-result performance traces ([#9899](https://github.com/MetaMask/core/pull/9899))

### Changed

- Bump `@metamask/assets-controller` from `^13.1.2` to `^13.1.4` ([#9873](https://github.com/MetaMask/core/pull/9873), [#9886](https://github.com/MetaMask/core/pull/9886))
Expand Down
160 changes: 160 additions & 0 deletions packages/bridge-controller/src/bridge-controller.sse.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BigNumber } from '@ethersproject/bignumber';
import * as ethersContractUtils from '@ethersproject/contracts';
import type { TraceRequest } from '@metamask/controller-utils';
import { SolScope } from '@metamask/keyring-api';
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
import type {
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
DEFAULT_BRIDGE_CONTROLLER_STATE,
ETH_USDT_ADDRESS,
} from './constants/bridge.js';
import { TraceName } from './constants/traces.js';
import { ChainId, RequestStatus } from './types.js';
import type { BridgeControllerMessenger } from './types.js';
import * as balanceUtils from './utils/balance.js';
Expand All @@ -47,12 +49,17 @@ import {
} from './utils/caip-formatters.js';
import * as featureFlagUtils from './utils/feature-flags.js';
import * as fetchUtils from './utils/fetch.js';
import { AbortReason } from './utils/metrics/constants.js';
import { FeatureId } from './validators/feature-flags.js';
import { validateQuoteResponseV1 } from './validators/quote-response-v1.js';
import { QuoteStreamCompleteReason } from './validators/quote-stream-complete.js';
import { TokenFeatureType } from './validators/token-feature.js';
import type { TxData } from './validators/trade.js';

jest.mock('uuid', () => ({
v4: (): string => 'test-uuid-1234',
}));

type RootMessenger = Messenger<
MockAnyNamespace,
MessengerActions<BridgeControllerMessenger>,
Expand Down Expand Up @@ -104,6 +111,16 @@ const metricsContext = {
token_security_type_destination: null,
};

const createTraceCallback = (traceRequests: TraceRequest[]): jest.Mock =>
jest
.fn()
.mockImplementation(
async (request: TraceRequest, callback?: () => unknown) => {
traceRequests.push(request);
return await callback?.();
},
);

const assetExchangeRates = {
'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': {
exchangeRate: undefined,
Expand Down Expand Up @@ -242,6 +259,149 @@ describe('BridgeController SSE', function () {
jest.resetAllMocks();
});

describe('quote tracing', () => {
const runTraceScenario = async ({
response,
request = quoteRequest,
abort = false,
}: {
response: () => unknown;
request?: typeof quoteRequest;
abort?: boolean;
}): Promise<TraceRequest[]> => {
const traceRequests: TraceRequest[] = [];

await withController(
{
options: {
traceFn: createTraceCallback(traceRequests),
},
},
async ({ controller, rootMessenger }) => {
mockFetchFn.mockImplementationOnce(async () => response());
await rootMessenger.call(
'BridgeController:updateBridgeQuoteRequestParams',
request,
metricsContext,
);
jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();
if (abort) {
controller.stopPollingForQuotes(AbortReason.NewQuoteRequest);
}
jest.advanceTimersByTime(11000);
await flushPromises();
},
);

return traceRequests;
};

const getTrace = (
requests: TraceRequest[],
name: TraceName,
): TraceRequest | undefined =>
requests.find((request) => request.name === name);

it('records cross-chain success and the first result from each provider', async () => {
const firstQuote = mockBridgeQuotesErc20Erc20V1[0];
const secondProviderQuote = {
...firstQuote,
quote: {
...firstQuote.quote,
requestId: 'second-provider-request',
bridgeId: 'hop',
bridges: ['hop'],
protocols: ['hop'],
},
};

const requests = await runTraceScenario({
response: (): unknown =>
mockSseEventSource([firstQuote, firstQuote, secondProviderQuote]),
});
const providerTraces = requests.filter(
(request) => request.name === TraceName.QuoteProviderFirstResult,
);

expect(
getTrace(requests, TraceName.BridgeQuotesFetched)?.data,
).toStrictEqual(
expect.objectContaining({
request_id: 'test-uuid-1234',
feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
result: 'success',
}),
);
expect(
providerTraces.map((request) => request.data?.provider),
).toStrictEqual(['socket_across', 'hop_hop']);
expect(
providerTraces.every((request) => Number.isFinite(request.startTime)),
).toBe(true);
});

it.each([
{
name: 'same-chain success',
response: (): unknown =>
mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]]),
request: {
...quoteRequest,
destChainId: quoteRequest.srcChainId,
},
traceName: TraceName.SwapQuotesFetched,
result: 'success',
providerCount: 1,
},
{
name: 'no quotes',
response: (): unknown => mockSseEventSource([]),
request: quoteRequest,
traceName: TraceName.BridgeQuotesFetched,
result: 'no_quotes',
providerCount: 0,
},
{
name: 'error',
response: (): unknown => mockSseServerError('provider request failed'),
request: quoteRequest,
traceName: TraceName.BridgeQuotesFetched,
result: 'error',
providerCount: 0,
},
])('records $name', async (scenario) => {
const requests = await runTraceScenario(scenario);

expect(getTrace(requests, scenario.traceName)?.data).toStrictEqual(
expect.objectContaining({
result: scenario.result,
}),
);
expect(
requests.filter(
(request) => request.name === TraceName.QuoteProviderFirstResult,
),
).toHaveLength(scenario.providerCount);
});

it('records cancellation for an expected abort', async () => {
const requests = await runTraceScenario({
response: (): unknown =>
mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]], 10000),
abort: true,
});

expect(
getTrace(requests, TraceName.BridgeQuotesFetched)?.data,
).toStrictEqual(
expect.objectContaining({
result: 'cancelled',
}),
);
});
});

it('should trigger quote polling if request is valid', async function () {
await withController(
async ({
Expand Down
3 changes: 1 addition & 2 deletions packages/bridge-controller/src/bridge-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2180,8 +2180,7 @@ describe('BridgeController', function () {
);

// Trigger the fetch + abort rejection
jest.advanceTimersByTime(1000);
await flushPromises();
await jest.advanceTimersByTimeAsync(1000);

// Early return path: no post-fetch updates
expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1);
Expand Down
Loading