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/shield-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

- Added metrics in the Shield coverage response to track the latency ( [#7133](https://github.com/MetaMask/core/pull/7133))

## [2.0.0]

### Changed
Expand Down
115 changes: 110 additions & 5 deletions packages/shield-controller/src/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ function setup({
}

describe('ShieldRemoteBackend', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should check coverage', async () => {
const { backend, fetchMock, getAccessToken } = setup();

Expand All @@ -70,14 +74,24 @@ describe('ShieldRemoteBackend', () => {

const txMeta = generateMockTxMeta();
const coverageResult = await backend.checkCoverage({ txMeta });
expect(coverageResult).toStrictEqual({ coverageId, ...result });
expect({
coverageId: coverageResult.coverageId,
message: result.message,
reasonCode: result.reasonCode,
status: result.status,
}).toStrictEqual({
coverageId,
...result,
});
expect(typeof coverageResult.metrics.latency).toBe('number');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAccessToken).toHaveBeenCalledTimes(2);
});

it('should check coverage with delay', async () => {
const pollInterval = 100;
const { backend, fetchMock, getAccessToken } = setup({
getCoverageResultPollInterval: 100,
getCoverageResultPollInterval: pollInterval,
});

// Mock init coverage check.
Expand All @@ -101,13 +115,38 @@ describe('ShieldRemoteBackend', () => {
} as unknown as Response);

const txMeta = generateMockTxMeta();

// generateMockTxMeta also use Date.now() to set the time, only do this after generateMockTxMeta
// Mock Date.now() to control latency measurement
// Simulate latency that includes the retry delay (poll interval + processing time)
let callCount = 0;
const startTime = 1000;
const expectedLatency = pollInterval + 50; // poll interval + processing time
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
callCount += 1;
// First call: start of #getCoverageResult
if (callCount === 1) {
return startTime;
}
// Final call: end of #getCoverageResult (after retry delay)
return startTime + expectedLatency;
});

const coverageResult = await backend.checkCoverage({ txMeta });
expect(coverageResult).toStrictEqual({

expect(coverageResult).toMatchObject({
coverageId,
...result,
status: result.status,
message: result.message,
reasonCode: result.reasonCode,
});
expect(coverageResult.metrics.latency).toBe(expectedLatency);
// Latency should include the retry delay (at least the poll interval)
expect(coverageResult.metrics.latency).toBeGreaterThanOrEqual(pollInterval);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getAccessToken).toHaveBeenCalledTimes(2);

nowSpy.mockRestore();
});

it('should throw on init coverage check failure', async () => {
Expand Down Expand Up @@ -187,6 +226,66 @@ describe('ShieldRemoteBackend', () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});

it('returns latency in coverageResult', async () => {
const { backend, fetchMock } = setup();

fetchMock.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ coverageId: 'coverageId' }),
} as unknown as Response);

const result = { status: 'covered', message: 'ok', reasonCode: 'E104' };
fetchMock.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue(result),
} as unknown as Response);

let nowValue = 1000;
const latencyMs = 123;
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
const val = nowValue;
nowValue += latencyMs;
return val;
});

const txMeta = generateMockTxMeta();
const coverageResult = await backend.checkCoverage({ txMeta });
expect(coverageResult.metrics.latency).toBe(latencyMs);

nowSpy.mockRestore();
});

it('returns latency in signatureCoverageResult', async () => {
const { backend, fetchMock } = setup();

fetchMock.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ coverageId: 'coverageId' }),
} as unknown as Response);

const result = { status: 'covered', message: 'ok', reasonCode: 'E104' };
fetchMock.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue(result),
} as unknown as Response);

let nowValue = 2000;
const latencyMs = 456;
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
const val = nowValue;
nowValue += latencyMs;
return val;
});

const signatureRequest = generateMockSignatureRequest();
const coverageResult = await backend.checkSignatureCoverage({
signatureRequest,
});
expect(coverageResult.metrics.latency).toBe(latencyMs);

nowSpy.mockRestore();
});

describe('checkSignatureCoverage', () => {
it('should check signature coverage', async () => {
const { backend, fetchMock, getAccessToken } = setup();
Expand All @@ -209,10 +308,16 @@ describe('ShieldRemoteBackend', () => {
const coverageResult = await backend.checkSignatureCoverage({
signatureRequest,
});
expect(coverageResult).toStrictEqual({
expect({
coverageId: coverageResult.coverageId,
message: result.message,
reasonCode: result.reasonCode,
status: result.status,
}).toStrictEqual({
coverageId,
...result,
});
expect(typeof coverageResult.metrics.latency).toBe('number');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAccessToken).toHaveBeenCalledTimes(2);
});
Expand Down
26 changes: 24 additions & 2 deletions packages/shield-controller/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export type GetCoverageResultResponse = {
message?: string;
reasonCode?: string;
status: CoverageStatus;
metrics: {
latency?: number;
};
};

export class ShieldRemoteBackend implements ShieldBackend {
Expand Down Expand Up @@ -117,6 +120,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
message: coverageResult.message,
reasonCode: coverageResult.reasonCode,
status: coverageResult.status,
metrics: coverageResult.metrics,
};
}

Expand All @@ -143,6 +147,7 @@ export class ShieldRemoteBackend implements ShieldBackend {
message: coverageResult.message,
reasonCode: coverageResult.reasonCode,
status: coverageResult.status,
metrics: coverageResult.metrics,
};
}

Expand Down Expand Up @@ -220,15 +225,20 @@ export class ShieldRemoteBackend implements ShieldBackend {

const headers = await this.#createHeaders();

// Start measuring total end-to-end latency including retries and delays
const startTime = Date.now();

const getCoverageResultFn = async (signal: AbortSignal) => {
const res = await this.#fetch(coverageResultUrl, {
method: 'POST',
headers,
body: JSON.stringify(reqBody),
signal,
});

if (res.status === 200) {
return (await res.json()) as GetCoverageResultResponse;
// Return the result without latency here - we'll add total latency after polling completes
return (await res.json()) as Omit<GetCoverageResultResponse, 'metrics'>;
}

// parse the error message from the response body
Expand All @@ -242,7 +252,19 @@ export class ShieldRemoteBackend implements ShieldBackend {
throw new HttpError(res.status, errorMessage);
};

return this.#pollingPolicy.start(requestId, getCoverageResultFn);
const result = await this.#pollingPolicy.start(
requestId,
getCoverageResultFn,
);

// Calculate total end-to-end latency including all retries and delays
const now = Date.now();
const totalLatency = now - startTime;

return {
...result,
metrics: { latency: totalLatency },
} as GetCoverageResultResponse;
}

async #createHeaders() {
Expand Down
3 changes: 3 additions & 0 deletions packages/shield-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export type CoverageResult = {
message?: string;
reasonCode?: string;
status: CoverageStatus;
metrics: {
latency?: number;
};
};

export const coverageStatuses = ['covered', 'malicious', 'unknown'] as const;
Expand Down
Loading