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

## [Unreleased]

### Added

- Add SwapBridge failure telemetry schema and quote-fetch classifier for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947))
- New exports: `FailurePhase`, `SwapBridgeErrorCode`, and `getQuoteFetchErrorCode` (classifies quote fetch from the code path, not from `error_message`)
- Optional `failure_phase` and `error_code` on Quotes Error and Failed event context types
- Optional `source_hash_present` and `destination_hash_present` on Failed, Submitted, and Completed event context types
- Submit and status classifiers live in `@metamask/bridge-status-controller`

### Changed

- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980))
Expand Down
6 changes: 6 additions & 0 deletions packages/bridge-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export {
InputAmountPreset,
MetaMetricsSwapsEventSource,
PollingStatus,
FailurePhase,
SwapBridgeErrorCode,
} from './utils/metrics/constants.js';

export type { BridgeControllerMetricsEventName } from './utils/metrics/constants.js';
Expand All @@ -25,6 +27,8 @@ export type {
QuoteFetchData,
QuoteWarning,
InputPrimaryDenominationData,
HashPresenceData,
FailureTelemetryData,
} from './utils/metrics/types.js';

export {
Expand All @@ -37,6 +41,8 @@ export {
getQuotesReceivedProperties,
} from './utils/metrics/properties.js';

export { getQuoteFetchErrorCode } from './utils/metrics/failure-telemetry.js';

export type {
ChainConfiguration,
L1GasFees,
Expand Down
25 changes: 25 additions & 0 deletions packages/bridge-controller/src/utils/metrics/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,28 @@ export enum MetricsSwapType {
SINGLE = 'single_chain',
CROSSCHAIN = 'crosschain',
}

/**
* When a SwapBridge attempt failed. Derived from the failing code path plus
* hash presence — never from `error_message` text.
*/
export enum FailurePhase {
Quote = 'quote',
Broadcast = 'broadcast',
SourceExecution = 'source_execution',
DestinationExecution = 'destination_execution',
Poll = 'poll',
Unknown = 'unknown',
}

/**
* Stable Mixpanel reason for a SwapBridge failure. Independent of free-text
* `error_message`.
*/
export enum SwapBridgeErrorCode {
QuoteFetchFailed = 'quote_fetch_failed',
MissingErrorObject = 'missing_error_object',
NonErrorRejection = 'non_error_rejection',
StatusFailedWithoutReason = 'status_failed_without_reason',
Unknown = 'unknown',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { SwapBridgeErrorCode } from './constants.js';
import { getQuoteFetchErrorCode } from './failure-telemetry.js';

describe('failure-telemetry', () => {
describe('getQuoteFetchErrorCode', () => {
it('returns missing_error_object when nothing was thrown', () => {
expect(getQuoteFetchErrorCode(undefined)).toBe(
SwapBridgeErrorCode.MissingErrorObject,
);
expect(getQuoteFetchErrorCode(null)).toBe(
SwapBridgeErrorCode.MissingErrorObject,
);
});

it('returns quote_fetch_failed for Error instances', () => {
expect(getQuoteFetchErrorCode(new Error('Network error'))).toBe(
SwapBridgeErrorCode.QuoteFetchFailed,
);
});

it('returns non_error_rejection for strings and plain objects', () => {
expect(getQuoteFetchErrorCode('timeout')).toBe(
SwapBridgeErrorCode.NonErrorRejection,
);
expect(getQuoteFetchErrorCode({ reason: 'no quotes' })).toBe(
SwapBridgeErrorCode.NonErrorRejection,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { SwapBridgeErrorCode } from './constants.js';

/**
* Classify a thrown value for Quotes Error. Quote fetch always stays in the
* `quote` phase; this only chooses `error_code`.
*
* @param error - The thrown value from quote fetch.
* @returns The Mixpanel `error_code`.
*/
export const getQuoteFetchErrorCode = (error: unknown): SwapBridgeErrorCode => {
if (error === undefined || error === null) {
return SwapBridgeErrorCode.MissingErrorObject;
}
if (error instanceof Error) {
return SwapBridgeErrorCode.QuoteFetchFailed;
}
return SwapBridgeErrorCode.NonErrorRejection;
};
23 changes: 19 additions & 4 deletions packages/bridge-controller/src/utils/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
MetricsActionType,
MetricsSwapType,
PollingStatus,
FailurePhase,
SwapBridgeErrorCode,
} from './constants.js';

/**
Expand Down Expand Up @@ -82,6 +84,16 @@ export type TxStatusData = {
destination_transaction?: StatusTypes;
};

export type HashPresenceData = {
source_hash_present?: boolean;
destination_hash_present?: boolean;
};

export type FailureTelemetryData = HashPresenceData & {
failure_phase?: FailurePhase;
error_code?: SwapBridgeErrorCode;
};

export type InputPrimaryDenominationData = {
input_primary_denomination?: InputPrimaryDenomination;
};
Expand Down Expand Up @@ -242,7 +254,8 @@ type RequiredEventContextFromClientBase = {
> & {
token_symbol_source: RequestParams['token_symbol_source'];
token_symbol_destination: RequestParams['token_symbol_destination'];
} & Pick<RequestMetadata, 'security_warnings'>;
} & Pick<RequestMetadata, 'security_warnings'> &
Pick<FailureTelemetryData, 'failure_phase' | 'error_code'>;
Comment thread
Battambang marked this conversation as resolved.
// Emitted by BridgeStatusController
[UnifiedSwapBridgeEventName.Submitted]: TradeData &
Pick<QuoteFetchData, 'price_impact'> &
Expand All @@ -259,7 +272,8 @@ type RequiredEventContextFromClientBase = {
> & {
action_type: MetricsActionType;
batch_id?: string;
} & InputPrimaryDenominationData;
} & InputPrimaryDenominationData &
HashPresenceData;
[UnifiedSwapBridgeEventName.Completed]: TradeData &
Pick<QuoteFetchData, 'price_impact'> &
Omit<RequestMetadata, 'security_warnings'> &
Expand All @@ -273,7 +287,8 @@ type RequiredEventContextFromClientBase = {
action_type: MetricsActionType;
batch_id?: string;
transaction_internal_id?: string;
} & InputPrimaryDenominationData;
} & InputPrimaryDenominationData &
HashPresenceData;
[UnifiedSwapBridgeEventName.Failed]: (
| // Tx failed before confirmation
(Pick<
Expand Down Expand Up @@ -302,7 +317,7 @@ type RequiredEventContextFromClientBase = {
Pick<QuoteFetchData, 'price_impact'> & {
error_message: string;
batch_id?: string;
};
} & FailureTelemetryData;
[UnifiedSwapBridgeEventName.PollingStatusUpdated]: {
polling_status: PollingStatus;
retry_attempts: number;
Expand Down
6 changes: 6 additions & 0 deletions packages/bridge-status-controller/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]

### Added

- Add SwapBridge submit and status failure telemetry classifiers for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947))
- New exports: `getHashPresenceProperties`, `getStatusFailurePhase`, `getSubmitErrorCode`, `getSubmitFailureTelemetry`, and `getStatusFailureTelemetry`
- Classifies submit and status failures from the code path (not from `error_message`)

### Fixed

- Preserve explicit slippage intent and normalized slippage limits in post-submission Unified SwapBridge metrics ([#9986](https://github.com/MetaMask/core/pull/9986))
Expand Down
12 changes: 12 additions & 0 deletions packages/bridge-status-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,15 @@ export {
getBatchSellHistoryItemsForTxHash,
isBatchSellHistoryItem,
} from './utils/history.js';

export {
getHashPresenceProperties,
getStatusFailurePhase,
getStatusFailureTelemetry,
getSubmitErrorCode,
getSubmitFailureTelemetry,
} from './utils/metrics.js';
export type {
FailureTelemetryProperties,
HashPresenceProperties,
} from './utils/metrics.js';
110 changes: 110 additions & 0 deletions packages/bridge-status-controller/src/utils/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
FeatureId,
getQuotesReceivedProperties,
MetaMetricsSwapsEventSource,
FailurePhase,
SwapBridgeErrorCode,
} from '@metamask/bridge-controller';
import {
MetricsSwapType,
Expand All @@ -26,6 +28,11 @@ import {
getRequestMetadataFromHistory,
getEVMTxPropertiesFromTransactionMeta,
getPreConfirmationPropertiesFromQuote,
getHashPresenceProperties,
getStatusFailurePhase,
getStatusFailureTelemetry,
getSubmitErrorCode,
getSubmitFailureTelemetry,
} from './metrics.js';

describe('metrics utils', () => {
Expand Down Expand Up @@ -1238,4 +1245,107 @@ describe('metrics utils', () => {
expect(result.swap_type).toBe(MetricsSwapType.SINGLE);
});
});

describe('getSubmitErrorCode', () => {
it('maps null to missing_error_object and Error to unknown', () => {
expect(getSubmitErrorCode(null)).toBe(
SwapBridgeErrorCode.MissingErrorObject,
);
expect(getSubmitErrorCode(new Error('snap failed'))).toBe(
SwapBridgeErrorCode.Unknown,
);
});

it('maps non-Error values to non_error_rejection', () => {
expect(getSubmitErrorCode('rejected')).toBe(
SwapBridgeErrorCode.NonErrorRejection,
);
expect(getSubmitErrorCode({ code: 4001 })).toBe(
SwapBridgeErrorCode.NonErrorRejection,
);
});
});

describe('getHashPresenceProperties', () => {
it('treats empty and missing hashes as absent', () => {
expect(getHashPresenceProperties(undefined, null)).toStrictEqual({
source_hash_present: false,
destination_hash_present: false,
});
expect(getHashPresenceProperties('', '')).toStrictEqual({
source_hash_present: false,
destination_hash_present: false,
});
});

it('flags hashes independently', () => {
expect(getHashPresenceProperties('0xabc', undefined)).toStrictEqual({
source_hash_present: true,
destination_hash_present: false,
});
expect(getHashPresenceProperties('0xabc', '0xdef')).toStrictEqual({
source_hash_present: true,
destination_hash_present: true,
});
});
});

describe('getStatusFailurePhase', () => {
it('prefers destination_execution, then source_execution, then poll', () => {
expect(
getStatusFailurePhase({
source_hash_present: true,
destination_hash_present: true,
}),
).toBe(FailurePhase.DestinationExecution);
expect(
getStatusFailurePhase({
source_hash_present: true,
destination_hash_present: false,
}),
).toBe(FailurePhase.SourceExecution);
expect(
getStatusFailurePhase({
source_hash_present: false,
destination_hash_present: false,
}),
).toBe(FailurePhase.Poll);
});
});

describe('getSubmitFailureTelemetry', () => {
it('uses broadcast for submit failures with no hash', () => {
expect(getSubmitFailureTelemetry(new Error('snap failed'))).toStrictEqual(
{
failure_phase: FailurePhase.Broadcast,
error_code: SwapBridgeErrorCode.Unknown,
source_hash_present: false,
destination_hash_present: false,
},
);
expect(getSubmitFailureTelemetry({ code: 4001 })).toStrictEqual({
failure_phase: FailurePhase.Broadcast,
error_code: SwapBridgeErrorCode.NonErrorRejection,
source_hash_present: false,
destination_hash_present: false,
});
});
});

describe('getStatusFailureTelemetry', () => {
it('uses status_failed_without_reason and phase from hashes', () => {
expect(getStatusFailureTelemetry('0xsrc', undefined)).toStrictEqual({
failure_phase: FailurePhase.SourceExecution,
error_code: SwapBridgeErrorCode.StatusFailedWithoutReason,
source_hash_present: true,
destination_hash_present: false,
});
expect(getStatusFailureTelemetry('0xsrc', '0xdest')).toStrictEqual({
failure_phase: FailurePhase.DestinationExecution,
error_code: SwapBridgeErrorCode.StatusFailedWithoutReason,
source_hash_present: true,
destination_hash_present: true,
});
});
});
});
Loading