From c253c16e6b260aa68027cb17554f2f38989ed8e0 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Mon, 24 Aug 2026 23:16:14 +0200 Subject: [PATCH 1/5] feat(bridge-controller): add SwapBridge failure telemetry schema and classifiers Add FailurePhase and SwapBridgeErrorCode plus helpers so a follow-up emit can classify failures from the code path and hash presence, without parsing error_message. No Mixpanel payloads change in this commit. --- packages/bridge-controller/CHANGELOG.md | 6 + packages/bridge-controller/src/index.ts | 13 ++ .../src/utils/metrics/constants.ts | 25 ++++ .../utils/metrics/failure-telemetry.test.ts | 140 ++++++++++++++++++ .../src/utils/metrics/failure-telemetry.ts | 114 ++++++++++++++ .../src/utils/metrics/types.ts | 23 ++- 6 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts create mode 100644 packages/bridge-controller/src/utils/metrics/failure-telemetry.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index cd18e0d6c6a..59dd175645a 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-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 failure telemetry enums and classifiers for a later emit + - New exports: `FailurePhase`, `SwapBridgeErrorCode`, and helpers that classify quote, submit, and status failures from the code path (not from `error_message`) + - Optional `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error, Failed, Submitted, and Completed event context types + ### Changed - Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index edadc586175..ad392528b99 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,15 @@ export { getQuotesReceivedProperties, } from './utils/metrics/properties.js'; +export { + getHashPresenceProperties, + getQuoteFetchErrorCode, + getStatusFailurePhase, + getStatusFailureTelemetry, + getSubmitErrorCode, + getSubmitFailureTelemetry, +} 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 791da068b0d..06dd30e8762 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 00000000000..5a5afbc73fa --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts @@ -0,0 +1,140 @@ +import { FailurePhase, SwapBridgeErrorCode } from './constants.js'; +import { + getHashPresenceProperties, + getQuoteFetchErrorCode, + getStatusFailurePhase, + getStatusFailureTelemetry, + getSubmitErrorCode, + getSubmitFailureTelemetry, +} 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, + ); + }); + }); + + 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-controller/src/utils/metrics/failure-telemetry.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts new file mode 100644 index 00000000000..dbac227faa0 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts @@ -0,0 +1,114 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { FailurePhase, SwapBridgeErrorCode } from './constants.js'; + +export type HashPresenceProperties = { + source_hash_present: boolean; + destination_hash_present: boolean; +}; + +export type FailureTelemetryProperties = HashPresenceProperties & { + failure_phase: FailurePhase; + error_code: SwapBridgeErrorCode; +}; + +/** + * 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; +}; + +/** + * 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, + }; +}; diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 42405645d96..1dc2a042e3d 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; From 4a23828b27c30fd8c51ee8a823b16b1cea189b23 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Mon, 24 Aug 2026 23:36:18 +0200 Subject: [PATCH 2/5] chore(bridge-controller): add PR link to failure telemetry changelog --- packages/bridge-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 59dd175645a..00760a4108f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add SwapBridge failure telemetry enums and classifiers for a later emit +- Add SwapBridge failure telemetry enums and classifiers for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947)) - New exports: `FailurePhase`, `SwapBridgeErrorCode`, and helpers that classify quote, submit, and status failures from the code path (not from `error_message`) - Optional `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error, Failed, Submitted, and Completed event context types From 7fc461cb5266eb6623eee728097d49075c1a61e3 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Wed, 26 Aug 2026 12:41:49 +0200 Subject: [PATCH 3/5] chore(bridge-controller): clarify Quotes Error has no hash-presence fields Quote fetch is pre-tx, so the changelog should not list source_hash_present or destination_hash_present on Quotes Error. --- packages/bridge-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 00760a4108f..6a76fc5a62f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add SwapBridge failure telemetry enums and classifiers for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947)) - New exports: `FailurePhase`, `SwapBridgeErrorCode`, and helpers that classify quote, submit, and status failures from the code path (not from `error_message`) - - Optional `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error, Failed, Submitted, and Completed event context types + - 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 ### Changed From 6dd1321ab8c5a5cfa09662e75cbb43122ffd9181 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Thu, 27 Aug 2026 17:52:57 +0200 Subject: [PATCH 4/5] refactor(bridge): move submit/status failure classifiers to status-controller Keep quote-fetch classification in bridge-controller. Hash presence and submit/status helpers belong with the controller that will emit them. --- packages/bridge-controller/CHANGELOG.md | 5 +- packages/bridge-controller/src/index.ts | 9 +- .../utils/metrics/failure-telemetry.test.ts | 114 +----------------- .../src/utils/metrics/failure-telemetry.ts | 98 +-------------- .../bridge-status-controller/CHANGELOG.md | 6 + .../bridge-status-controller/src/index.ts | 12 ++ .../src/utils/failure-telemetry.test.ts | 114 ++++++++++++++++++ .../src/utils/failure-telemetry.ts | 100 +++++++++++++++ 8 files changed, 239 insertions(+), 219 deletions(-) create mode 100644 packages/bridge-status-controller/src/utils/failure-telemetry.test.ts create mode 100644 packages/bridge-status-controller/src/utils/failure-telemetry.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 6a76fc5a62f..c82d9618bf3 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add SwapBridge failure telemetry enums and classifiers for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947)) - - New exports: `FailurePhase`, `SwapBridgeErrorCode`, and helpers that classify quote, submit, and status failures from the code path (not from `error_message`) +- 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 diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index ad392528b99..277c4a09e2d 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -41,14 +41,7 @@ export { getQuotesReceivedProperties, } from './utils/metrics/properties.js'; -export { - getHashPresenceProperties, - getQuoteFetchErrorCode, - getStatusFailurePhase, - getStatusFailureTelemetry, - getSubmitErrorCode, - getSubmitFailureTelemetry, -} from './utils/metrics/failure-telemetry.js'; +export { getQuoteFetchErrorCode } from './utils/metrics/failure-telemetry.js'; export type { ChainConfiguration, diff --git a/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts index 5a5afbc73fa..4dd394e9178 100644 --- a/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.test.ts @@ -1,12 +1,5 @@ -import { FailurePhase, SwapBridgeErrorCode } from './constants.js'; -import { - getHashPresenceProperties, - getQuoteFetchErrorCode, - getStatusFailurePhase, - getStatusFailureTelemetry, - getSubmitErrorCode, - getSubmitFailureTelemetry, -} from './failure-telemetry.js'; +import { SwapBridgeErrorCode } from './constants.js'; +import { getQuoteFetchErrorCode } from './failure-telemetry.js'; describe('failure-telemetry', () => { describe('getQuoteFetchErrorCode', () => { @@ -34,107 +27,4 @@ describe('failure-telemetry', () => { ); }); }); - - 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-controller/src/utils/metrics/failure-telemetry.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts index dbac227faa0..9d3c213275d 100644 --- a/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts @@ -1,15 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { FailurePhase, SwapBridgeErrorCode } from './constants.js'; - -export type HashPresenceProperties = { - source_hash_present: boolean; - destination_hash_present: boolean; -}; - -export type FailureTelemetryProperties = HashPresenceProperties & { - failure_phase: FailurePhase; - error_code: SwapBridgeErrorCode; -}; +import { SwapBridgeErrorCode } from './constants.js'; /** * Classify a thrown value for Quotes Error. Quote fetch always stays in the @@ -27,88 +16,3 @@ export const getQuoteFetchErrorCode = (error: unknown): SwapBridgeErrorCode => { } return SwapBridgeErrorCode.NonErrorRejection; }; - -/** - * 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, - }; -}; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index f6376b2212e..01bcf2d3784 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`) + ### Fixed - Preserve explicit slippage intent and normalized slippage limits in post-submission Unified SwapBridge metrics ([#9986](https://github.com/MetaMask/core/pull/9986)) diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index 78e3ecda008..01488a43a0f 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/failure-telemetry.js'; +export type { + FailureTelemetryProperties, + HashPresenceProperties, +} from './utils/failure-telemetry.js'; diff --git a/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts b/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts new file mode 100644 index 00000000000..c8ce2eaf6e2 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts @@ -0,0 +1,114 @@ +import { FailurePhase, SwapBridgeErrorCode } from '@metamask/bridge-controller'; + +import { + getHashPresenceProperties, + getStatusFailurePhase, + getStatusFailureTelemetry, + getSubmitErrorCode, + getSubmitFailureTelemetry, +} from './failure-telemetry.js'; + +describe('failure-telemetry', () => { + 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/failure-telemetry.ts b/packages/bridge-status-controller/src/utils/failure-telemetry.ts new file mode 100644 index 00000000000..6f8d40f4b8e --- /dev/null +++ b/packages/bridge-status-controller/src/utils/failure-telemetry.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + FailurePhase, + SwapBridgeErrorCode, +} from '@metamask/bridge-controller'; + +export type HashPresenceProperties = { + source_hash_present: boolean; + destination_hash_present: boolean; +}; + +export type FailureTelemetryProperties = HashPresenceProperties & { + failure_phase: FailurePhase; + error_code: SwapBridgeErrorCode; +}; + +/** + * 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, + }; +}; From ce2699e99f9facc081033a02b486708288bc85af Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Thu, 27 Aug 2026 18:05:52 +0200 Subject: [PATCH 5/5] refactor(bridge-status-controller): colocate failure classifiers in metrics utils Fold submit/status failure telemetry helpers into the existing metrics files instead of a new module. --- .../bridge-status-controller/src/index.ts | 4 +- .../src/utils/failure-telemetry.test.ts | 114 ------------------ .../src/utils/failure-telemetry.ts | 100 --------------- .../src/utils/metrics.test.ts | 110 +++++++++++++++++ .../src/utils/metrics.ts | 97 +++++++++++++++ 5 files changed, 209 insertions(+), 216 deletions(-) delete mode 100644 packages/bridge-status-controller/src/utils/failure-telemetry.test.ts delete mode 100644 packages/bridge-status-controller/src/utils/failure-telemetry.ts diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts index 01488a43a0f..f2da1475591 100644 --- a/packages/bridge-status-controller/src/index.ts +++ b/packages/bridge-status-controller/src/index.ts @@ -59,8 +59,8 @@ export { getStatusFailureTelemetry, getSubmitErrorCode, getSubmitFailureTelemetry, -} from './utils/failure-telemetry.js'; +} from './utils/metrics.js'; export type { FailureTelemetryProperties, HashPresenceProperties, -} from './utils/failure-telemetry.js'; +} from './utils/metrics.js'; diff --git a/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts b/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts deleted file mode 100644 index c8ce2eaf6e2..00000000000 --- a/packages/bridge-status-controller/src/utils/failure-telemetry.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { FailurePhase, SwapBridgeErrorCode } from '@metamask/bridge-controller'; - -import { - getHashPresenceProperties, - getStatusFailurePhase, - getStatusFailureTelemetry, - getSubmitErrorCode, - getSubmitFailureTelemetry, -} from './failure-telemetry.js'; - -describe('failure-telemetry', () => { - 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/failure-telemetry.ts b/packages/bridge-status-controller/src/utils/failure-telemetry.ts deleted file mode 100644 index 6f8d40f4b8e..00000000000 --- a/packages/bridge-status-controller/src/utils/failure-telemetry.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { - FailurePhase, - SwapBridgeErrorCode, -} from '@metamask/bridge-controller'; - -export type HashPresenceProperties = { - source_hash_present: boolean; - destination_hash_present: boolean; -}; - -export type FailureTelemetryProperties = HashPresenceProperties & { - failure_phase: FailurePhase; - error_code: SwapBridgeErrorCode; -}; - -/** - * 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, - }; -}; diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts index fa4202586d7..6d0be1c81b2 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 071b5abcf17..bc01afb5700 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, + }; +};