Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
300125e
feat(controller-utils): emit duration in slow-success degraded events
cryptodev-2s Apr 14, 2026
b803663
feat(network-controller): add duration and traceId to RpcServiceReque…
cryptodev-2s Apr 14, 2026
37e3ef4
feat(network-controller): add duration and traceId to RpcService onDe…
cryptodev-2s Apr 14, 2026
4e75fad
feat(network-controller): add duration and traceId to degraded event …
cryptodev-2s Apr 14, 2026
3b084de
test(network-controller): verify duration and traceId forwarding thro…
cryptodev-2s Apr 14, 2026
9fcdfe9
feat(network-controller): forward duration and traceId through messen…
cryptodev-2s Apr 14, 2026
1054f31
docs: add changelog entries for duration and traceId in degraded events
cryptodev-2s Apr 14, 2026
3c5c910
fix: remove extra blank line in network-controller changelog
cryptodev-2s Apr 14, 2026
8c6e11e
docs: add PR links to changelog entries
cryptodev-2s Apr 14, 2026
7fd6b35
style: fix formatting with oxfmt
cryptodev-2s Apr 14, 2026
615e7ba
fix: replace in operator with hasProperty to satisfy no-restricted-sy…
cryptodev-2s Apr 14, 2026
6113bfb
fix: cast duration to number to satisfy TypeScript
cryptodev-2s Apr 14, 2026
9166439
fix: remove dead void from onDegraded type, replace as-cast with type…
cryptodev-2s Apr 14, 2026
32dd6a9
fix: replace nested ternary and negated condition with if/else
cryptodev-2s Apr 14, 2026
d301f7d
fix: split onDegraded paths to satisfy TypeScript union narrowing
cryptodev-2s Apr 14, 2026
d6985be
fix: reset response between retry attempts to prevent stale traceId leak
cryptodev-2s Apr 14, 2026
63c1956
fix: address PR review feedback from mcmire
cryptodev-2s Apr 15, 2026
21cc53d
Merge branch 'main' into feat/degraded-event-duration-traceid
mcmire Apr 15, 2026
0e695d0
Revert changes to RpcServiceRequestable, fix type errors
mcmire Apr 15, 2026
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/controller-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** The `ServicePolicy` type's `onDegraded` event now emits `{ duration: number }` instead of `void` when the service succeeds but takes longer than the `degradedThreshold` ([#8455](https://github.com/MetaMask/core/pull/8455))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another big release 🙈🙈🙈

- `void` has been removed from the event's type union. Listeners that checked for `undefined` data should now check for the `duration` property instead.
- The event still emits a `FailureReason` when retries are exhausted.

## [11.20.0]

### Added
Expand Down
21 changes: 21 additions & 0 deletions packages/controller-utils/src/create-service-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,27 @@ describe('createServicePolicy', () => {
expect(onDegradedListener).toHaveBeenCalledTimes(1);
});

it('calls onDegraded listeners with duration when the request succeeds but is slow', async () => {
jest.useFakeTimers();
const policy = createServicePolicy({
degradedThreshold: 2000,
});
const onDegradedListener = jest.fn();

policy.onDegraded(onDegradedListener);
await policy.execute(async () => {
jest.advanceTimersByTime(2001);
return 'result';
});

expect(onDegradedListener).toHaveBeenCalledTimes(1);
expect(onDegradedListener).toHaveBeenCalledWith({
duration: expect.any(Number),
});
const { duration } = onDegradedListener.mock.calls[0][0];
expect(duration).toBeGreaterThan(2000);
});

it('does not call onAvailable listeners', async () => {
let invocationCounter = 0;
const delay = DEFAULT_DEGRADED_THRESHOLD + 1;
Expand Down
9 changes: 5 additions & 4 deletions packages/controller-utils/src/create-service-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export type ServicePolicy = IPolicy & {
* never succeeds before the retry policy gives up and before the maximum
* number of consecutive failures has been reached.
*/
onDegraded: CockatielEvent<FailureReason<unknown> | void>;
onDegraded: CockatielEvent<FailureReason<unknown> | { duration: number }>;
/**
* A function which is called when the service succeeds for the first time,
* or when the service fails enough times to cause the circuit to break and
Expand Down Expand Up @@ -321,8 +321,9 @@ export function createServicePolicy(
});
const onBreak = circuitBreakerPolicy.onBreak.bind(circuitBreakerPolicy);

const onDegradedEventEmitter =
new CockatielEventEmitter<FailureReason<unknown> | void>();
const onDegradedEventEmitter = new CockatielEventEmitter<
FailureReason<unknown> | { duration: number }
>();
const onDegraded = onDegradedEventEmitter.addListener;

const onAvailableEventEmitter = new CockatielEventEmitter<void>();
Expand All @@ -338,7 +339,7 @@ export function createServicePolicy(
if (circuitBreakerPolicy.state === CircuitState.Closed) {
if (duration > degradedThreshold) {
availabilityStatus = AVAILABILITY_STATUSES.Degraded;
onDegradedEventEmitter.emit();
onDegradedEventEmitter.emit({ duration });
} else if (availabilityStatus !== AVAILABILITY_STATUSES.Available) {
availabilityStatus = AVAILABILITY_STATUSES.Available;
onAvailableEventEmitter.emit();
Expand Down
3 changes: 3 additions & 0 deletions packages/network-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `NetworkController:loadBackup`
- Corresponding action types are available as well.
- Add `getEthQuery` method to `NetworkController` ([#8350](https://github.com/MetaMask/core/pull/8350))
- Add `duration` and `traceId` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#8455](https://github.com/MetaMask/core/pull/8455))
- `duration` contains the policy execution time in milliseconds when the request succeeded but was slow. It is `undefined` when retries were exhausted.
- `traceId` contains the value of the `x-trace-id` response header from the last request attempt, or `undefined` if the header was not present.

### Changed

Expand Down
14 changes: 14 additions & 0 deletions packages/network-controller/src/NetworkController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,21 +518,28 @@ export type NetworkControllerRpcEndpointUnavailableEvent = {
*
* @param payload - The event payload.
* @param payload.chainId - The target network's chain ID.
* @param payload.duration - The duration in milliseconds of the policy
* execution when the request succeeded but was slow. `undefined` when retries
* were exhausted.
* @param payload.error - The last error produced by the endpoint (or
* `undefined` if the request was slow).
* @param payload.networkClientId - The target network's client ID.
* @param payload.rpcMethodName - The JSON-RPC method that was being executed
* when the chain became degraded.
* @param payload.traceId - The value of the `x-trace-id` response header from
* the last request attempt, or `undefined` if the header was not present.
*/
export type NetworkControllerRpcEndpointChainDegradedEvent = {
type: 'NetworkController:rpcEndpointChainDegraded';
payload: [
{
chainId: Hex;
duration?: number;
error: unknown;
networkClientId: NetworkClientId;
retryReason?: RetryReason;
rpcMethodName: string;
traceId?: string;
type: DegradedEventType;
},
];
Expand All @@ -553,6 +560,9 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = {
*
* @param payload - The event payload.
* @param payload.chainId - The target network's chain ID.
* @param payload.duration - The duration in milliseconds of the policy
* execution when the request succeeded but was slow. `undefined` when retries
* were exhausted.
* @param payload.endpointUrl - The URL of the endpoint for which requests
* failed or were slow to complete. You can compare this to `primaryEndpointUrl`
* to know whether it was a failover or a primary.
Expand All @@ -562,18 +572,22 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = {
* @param payload.primaryEndpointUrl - The endpoint chain's primary URL.
* @param payload.rpcMethodName - The JSON-RPC method that was being executed
* when the endpoint became degraded.
* @param payload.traceId - The value of the `x-trace-id` response header from
* the last request attempt, or `undefined` if the header was not present.
*/
export type NetworkControllerRpcEndpointDegradedEvent = {
type: 'NetworkController:rpcEndpointDegraded';
payload: [
{
chainId: Hex;
duration?: number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to check to make sure that this also isn't a breaking change. I thought at first it wasn't, but then given that event payloads show up as arguments to callbacks, I am having second doubts.

endpointUrl: string;
error: unknown;
networkClientId: NetworkClientId;
primaryEndpointUrl: string;
retryReason?: RetryReason;
rpcMethodName: string;
traceId?: string;
type: DegradedEventType;
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -667,6 +669,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -780,6 +784,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
expect(
rpcEndpointDegradedEventHandler,
Expand All @@ -792,6 +798,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
expect(
rpcEndpointDegradedEventHandler,
Expand All @@ -804,6 +812,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -929,6 +939,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
expect(
rpcEndpointDegradedEventHandler,
Expand All @@ -941,6 +953,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
expect(
rpcEndpointDegradedEventHandler,
Expand All @@ -952,6 +966,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
rpcMethodName: 'eth_blockNumber',
duration: expect.any(Number),
traceId: undefined,
});
expect(
rpcEndpointDegradedEventHandler,
Expand All @@ -963,6 +979,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
rpcMethodName: 'eth_gasPrice',
duration: expect.any(Number),
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -1164,6 +1182,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -1242,6 +1262,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
rpcMethodName: 'eth_blockNumber',
duration: expect.any(Number),
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -1331,6 +1353,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
Expand All @@ -1341,6 +1365,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
duration: undefined,
traceId: undefined,
});
},
);
Expand Down Expand Up @@ -1572,6 +1598,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
rpcMethodName: 'eth_blockNumber',
duration: expect.any(Number),
traceId: undefined,
});
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
Expand All @@ -1581,6 +1609,8 @@ describe('createNetworkClient - RPC endpoint events', () => {
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
rpcMethodName: 'eth_gasPrice',
duration: expect.any(Number),
traceId: undefined,
});
},
);
Expand Down
35 changes: 22 additions & 13 deletions packages/network-controller/src/create-network-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,25 +350,32 @@ function createRpcServiceChain({
},
);

rpcServiceChain.onDegraded(({ rpcMethodName, ...rest }) => {
const error = getError(rest);
const type: DegradedEventType =
error === undefined ? 'slow_success' : 'retries_exhausted';
messenger.publish('NetworkController:rpcEndpointChainDegraded', {
chainId: configuration.chainId,
networkClientId: id,
error,
rpcMethodName,
type,
retryReason: error === undefined ? undefined : classifyRetryReason(error),
});
});
rpcServiceChain.onDegraded(
({ rpcMethodName, duration, traceId, ...rest }) => {
const error = getError(rest);
const type: DegradedEventType =
error === undefined ? 'slow_success' : 'retries_exhausted';
messenger.publish('NetworkController:rpcEndpointChainDegraded', {
chainId: configuration.chainId,
networkClientId: id,
error,
rpcMethodName,
duration,
traceId,
type,
retryReason:
error === undefined ? undefined : classifyRetryReason(error),
});
},
);

rpcServiceChain.onServiceDegraded(
({
endpointUrl,
primaryEndpointUrl: primaryEndpointUrlFromEvent,
rpcMethodName,
duration,
traceId,
...rest
}) => {
const error = getError(rest);
Expand All @@ -382,6 +389,8 @@ function createRpcServiceChain({
endpointUrl,
error,
rpcMethodName,
duration,
traceId,
type,
retryReason:
error === undefined ? undefined : classifyRetryReason(error),
Expand Down
Loading
Loading