diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index fc3584c880..2aaeb693fd 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -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` + ## [80.1.0] ### Added diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index edadc58617..277c4a09e2 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -9,6 +9,8 @@ export { InputAmountPreset, MetaMetricsSwapsEventSource, PollingStatus, + FailurePhase, + SwapBridgeErrorCode, } from './utils/metrics/constants.js'; export type { BridgeControllerMetricsEventName } from './utils/metrics/constants.js'; @@ -25,6 +27,8 @@ export type { QuoteFetchData, QuoteWarning, InputPrimaryDenominationData, + HashPresenceData, + FailureTelemetryData, } from './utils/metrics/types.js'; export { @@ -37,6 +41,8 @@ export { getQuotesReceivedProperties, } from './utils/metrics/properties.js'; +export { getQuoteFetchErrorCode } from './utils/metrics/failure-telemetry.js'; + export type { ChainConfiguration, L1GasFees, diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 791da068b0..06dd30e876 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -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', +} diff --git a/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts new file mode 100644 index 0000000000..4dd394e917 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts @@ -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, + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts new file mode 100644 index 0000000000..9d3c213275 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts @@ -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; +}; diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 42405645d9..1dc2a042e3 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -16,6 +16,8 @@ import type { MetricsActionType, MetricsSwapType, PollingStatus, + FailurePhase, + SwapBridgeErrorCode, } from './constants.js'; /** @@ -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; }; @@ -242,7 +254,8 @@ type RequiredEventContextFromClientBase = { > & { token_symbol_source: RequestParams['token_symbol_source']; token_symbol_destination: RequestParams['token_symbol_destination']; - } & Pick; + } & Pick & + Pick; // Emitted by BridgeStatusController [UnifiedSwapBridgeEventName.Submitted]: TradeData & Pick & @@ -259,7 +272,8 @@ type RequiredEventContextFromClientBase = { > & { action_type: MetricsActionType; batch_id?: string; - } & InputPrimaryDenominationData; + } & InputPrimaryDenominationData & + HashPresenceData; [UnifiedSwapBridgeEventName.Completed]: TradeData & Pick & Omit & @@ -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< @@ -302,7 +317,7 @@ type RequiredEventContextFromClientBase = { Pick & { error_message: string; batch_id?: string; - }; + } & FailureTelemetryData; [UnifiedSwapBridgeEventName.PollingStatusUpdated]: { polling_status: PollingStatus; retry_attempts: number; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a10ba1f315..b97712f368 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -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`) + ## [75.4.0] ### Added diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index 78e3ecda00..f2da147559 100644 --- a/packages/bridge-status-controller/src/index.ts +++ b/packages/bridge-status-controller/src/index.ts @@ -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'; diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts index fa4202586d..6d0be1c81b 100644 --- a/packages/bridge-status-controller/src/utils/metrics.test.ts +++ b/packages/bridge-status-controller/src/utils/metrics.test.ts @@ -5,6 +5,8 @@ import { FeatureId, getQuotesReceivedProperties, MetaMetricsSwapsEventSource, + FailurePhase, + SwapBridgeErrorCode, } from '@metamask/bridge-controller'; import { MetricsSwapType, @@ -26,6 +28,11 @@ import { getRequestMetadataFromHistory, getEVMTxPropertiesFromTransactionMeta, getPreConfirmationPropertiesFromQuote, + getHashPresenceProperties, + getStatusFailurePhase, + getStatusFailureTelemetry, + getSubmitErrorCode, + getSubmitFailureTelemetry, } from './metrics.js'; describe('metrics utils', () => { @@ -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, + }); + }); + }); }); diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 071b5abcf1..bc01afb570 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -17,6 +17,8 @@ import { MetaMetricsSwapsEventSource, FeatureId, UnifiedSwapBridgeEventName, + FailurePhase, + SwapBridgeErrorCode, } from '@metamask/bridge-controller'; import type { AccountHardwareType, @@ -45,6 +47,16 @@ import { getActualSwapReceivedAmount, } from './swap-received-amount.js'; +export type HashPresenceProperties = { + source_hash_present: boolean; + destination_hash_present: boolean; +}; + +export type FailureTelemetryProperties = HashPresenceProperties & { + failure_phase: FailurePhase; + error_code: SwapBridgeErrorCode; +}; + export const getTxStatusesFromHistory = ({ status, hasApprovalTx, @@ -369,3 +381,88 @@ export const getEVMTxPropertiesFromTransactionMeta = ( ...(transactionMeta.batchId ? { batch_id: transactionMeta.batchId } : {}), }; }; + +/** + * Classify a thrown value from submit (sign/broadcast) catch paths. + * + * @param error - The thrown value from submit. + * @returns The Mixpanel `error_code`. + */ +export const getSubmitErrorCode = (error: unknown): SwapBridgeErrorCode => { + if (error === undefined || error === null) { + return SwapBridgeErrorCode.MissingErrorObject; + } + if (error instanceof Error) { + return SwapBridgeErrorCode.Unknown; + } + return SwapBridgeErrorCode.NonErrorRejection; +}; + +/** + * @param sourceHash - Source tx hash if known at emit time. + * @param destinationHash - Destination tx hash if known at emit time. + * @returns Boolean hash-presence properties. + */ +export const getHashPresenceProperties = ( + sourceHash?: string | null, + destinationHash?: string | null, +): HashPresenceProperties => { + return { + source_hash_present: Boolean(sourceHash), + destination_hash_present: Boolean(destinationHash), + }; +}; + +/** + * Prefer destination_execution over source_execution over poll. + * + * @param hashPresence - Hash presence at emit time. + * @returns The Mixpanel `failure_phase` for a status/polling Failed event. + */ +export const getStatusFailurePhase = ( + hashPresence: HashPresenceProperties, +): FailurePhase => { + if (hashPresence.destination_hash_present) { + return FailurePhase.DestinationExecution; + } + if (hashPresence.source_hash_present) { + return FailurePhase.SourceExecution; + } + return FailurePhase.Poll; +}; + +/** + * Telemetry for Failed events emitted from the submit catch (no tx hash yet). + * + * @param error - The thrown value from submit. + * @returns Phase, error code, and hash-presence flags. + */ +export const getSubmitFailureTelemetry = ( + error: unknown, +): FailureTelemetryProperties => { + return { + failure_phase: FailurePhase.Broadcast, + error_code: getSubmitErrorCode(error), + source_hash_present: false, + destination_hash_present: false, + }; +}; + +/** + * Telemetry for Failed events derived from a status poll. + * + * @param sourceHash - Source tx hash if known. + * @param destinationHash - Destination tx hash if known. + * @returns Phase, error code, and hash-presence flags. + */ +export const getStatusFailureTelemetry = ( + sourceHash?: string | null, + destinationHash?: string | null, +): FailureTelemetryProperties => { + const hashPresence = getHashPresenceProperties(sourceHash, destinationHash); + return { + ...hashPresence, + failure_phase: getStatusFailurePhase(hashPresence), + error_code: SwapBridgeErrorCode.StatusFailedWithoutReason, + }; +};