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

## [Unreleased]

### Added

- Add optional `active_ab_tests` property in Unified SwapBridge metrics event context and payload types, alongside existing `ab_tests`. ([#8152](https://github.com/MetaMask/core/pull/8152))

## [69.0.1]

### Changed
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-controller/src/utils/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,15 @@ type RequiredEventContextFromClientBase = {
* This combines the event-specific properties from RequiredEventContextFromClientBase
* with an optional `location` property. When `location` is omitted, the controller
* falls back to the value stored via `setLocation()` (defaults to MainView).
*
* `ab_tests` is the legacy field and `active_ab_tests` is the newer field.
* Both are kept for a migration window and are treated as separate payloads.
*/
export type RequiredEventContextFromClient = {
[K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & {
location?: MetaMetricsSwapsEventSource;
ab_tests?: Record<string, string>;
active_ab_tests?: { key: string; value: string }[];
};
};

Expand Down Expand Up @@ -313,6 +317,9 @@ export type EventPropertiesFromControllerState = {
/**
* trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client
* and properties from the bridge controller
*
* `ab_tests` will be deprecated in favor of `active_ab_tests` in the future.
* `ab_tests` and `active_ab_tests` intentionally coexist during migration.
*/
export type CrossChainSwapsEventProperties<
T extends UnifiedSwapBridgeEventName,
Expand All @@ -321,6 +328,7 @@ export type CrossChainSwapsEventProperties<
action_type: MetricsActionType;
location: MetaMetricsSwapsEventSource;
ab_tests?: Record<string, string>;
active_ab_tests?: { key: string; value: string }[];
}
| Pick<EventPropertiesFromControllerState, T>[T]
| Pick<RequiredEventContextFromClient, T>[T];
4 changes: 4 additions & 0 deletions packages/bridge-status-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added optional `activeAbTests` context support so Unified SwapBridge events can include `active_ab_tests` independently of `ab_tests`. ([#8152](https://github.com/MetaMask/core/pull/8152))

## [68.0.2]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4255,14 +4255,15 @@ describe('BridgeStatusController', () => {
expect(messengerCallSpy.mock.lastCall).toMatchSnapshot();
});

it('should include ab_tests from history in tracked event properties', () => {
it('should include ab_tests and active_ab_tests from history in tracked event properties', () => {
const abTestsTxMetaId = 'bridgeTxMetaIdAbTests';
bridgeStatusController.startPollingForBridgeTxStatus({
...getMockStartPollingForBridgeTxStatusArgs({
txMetaId: abTestsTxMetaId,
srcTxHash: '0xsrcTxHashAbTests',
}),
abTests: { token_details_layout: 'treatment' },
activeAbTests: [{ key: 'bridge_quote_sorting', value: 'variant_b' }],
});

const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call');
Expand All @@ -4284,6 +4285,9 @@ describe('BridgeStatusController', () => {
expect.anything(),
expect.objectContaining({
ab_tests: { token_details_layout: 'treatment' },
active_ab_tests: [
{ key: 'bridge_quote_sorting', value: 'variant_b' },
],
}),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
isStxEnabled,
location,
abTests,
activeAbTests,
accountAddress: selectedAddress,
} = startPollingForBridgeTxStatusArgs;

Expand Down Expand Up @@ -570,6 +571,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
featureId: quoteResponse.featureId,
location,
...(abTests && { abTests }),
...(activeAbTests && { activeAbTests }),
};
this.update((state) => {
// Use actionId as key for pre-submission, or txMeta.id for post-submission
Expand Down Expand Up @@ -1308,7 +1310,8 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
* @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension
* @param quotesReceivedContext - The context for the QuotesReceived event
* @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore)
* @param abTests - A/B test context to attribute events to specific experiments
* @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility)
* @param activeAbTests - New A/B test context for `active_ab_tests` (migration target). Attributes events to specific experiments.
* @returns The transaction meta
*/
submitTx = async (
Expand All @@ -1318,6 +1321,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived],
location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView,
abTests?: Record<string, string>,
activeAbTests?: { key: string; value: string }[],
): Promise<TransactionMeta & Partial<SolanaTransactionMeta>> => {
this.messenger.call(
'BridgeController:stopPollingForQuotes',
Expand All @@ -1341,6 +1345,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
isHardwareAccount,
location,
abTests,
activeAbTests,
);
// Emit Submitted event after submit button is clicked
!quoteResponse.featureId &&
Expand Down Expand Up @@ -1522,6 +1527,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
approvalTxId,
location,
abTests,
activeAbTests,
},
actionId,
);
Expand Down Expand Up @@ -1572,6 +1578,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
approvalTxId,
location,
abTests,
activeAbTests,
});
}

Expand Down Expand Up @@ -1600,16 +1607,19 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
* @param params.quoteResponse - Quote carrying intent data
* @param params.accountAddress - The EOA submitting the order
* @param params.location - The entry point from which the user initiated the swap or bridge
* @param params.abTests - A/B test context to attribute events to specific experiments
* @param params.abTests - Legacy A/B test context for `ab_tests` (backward compatibility)
* @param params.activeAbTests - New A/B test context for `active_ab_tests` (migration target). Attributes events to specific experiments.
* @returns A lightweight TransactionMeta-like object for history linking
*/
submitIntent = async (params: {
quoteResponse: QuoteResponse<Trade, Trade> & QuoteMetadata;
accountAddress: string;
location?: MetaMetricsSwapsEventSource;
abTests?: Record<string, string>;
activeAbTests?: { key: string; value: string }[];
}): Promise<Pick<TransactionMeta, 'id' | 'chainId' | 'type' | 'status'>> => {
const { quoteResponse, accountAddress, location, abTests } = params;
const { quoteResponse, accountAddress, location, abTests, activeAbTests } =
params;

this.messenger.call(
'BridgeController:stopPollingForQuotes',
Expand All @@ -1625,6 +1635,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
isHardwareAccount,
location,
abTests,
activeAbTests,
);

try {
Expand Down Expand Up @@ -1773,6 +1784,7 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
startTime,
location,
abTests,
activeAbTests,
});

// Start polling using the orderId key to route to intent manager
Expand Down Expand Up @@ -1821,14 +1833,16 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
EventName
>[EventName],
): void => {
const { ab_tests: eventAbTests } =
(eventProperties as
| Record<string, Record<string, string> | undefined>
| undefined) ?? {};
// Legacy/new metrics fields are intentionally kept independent during migration.
const historyAbTests = txMetaId
? this.state.txHistory?.[txMetaId]?.abTests
: undefined;
const resolvedAbTests = eventAbTests ?? historyAbTests ?? undefined;
const historyActiveAbTests = txMetaId
? this.state.txHistory?.[txMetaId]?.activeAbTests
: undefined;
const resolvedAbTests = eventProperties?.ab_tests ?? historyAbTests;
const resolvedActiveAbTests =
eventProperties?.active_ab_tests ?? historyActiveAbTests;

const baseProperties = {
action_type: MetricsActionType.SWAPBRIDGE_V1,
Expand All @@ -1841,6 +1855,10 @@ export class BridgeStatusController extends StaticIntervalPollingController<Brid
Object.keys(resolvedAbTests).length > 0 && {
ab_tests: resolvedAbTests,
}),
...(resolvedActiveAbTests &&
resolvedActiveAbTests.length > 0 && {
active_ab_tests: resolvedActiveAbTests,
}),
};

// This will publish events for PERPS dropped tx failures as well
Expand Down
11 changes: 10 additions & 1 deletion packages/bridge-status-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,16 @@ export type BridgeHistoryItem = {
*/
location?: MetaMetricsSwapsEventSource;
/**
* A/B test context to attribute swap/bridge events to specific experiments.
* Legacy A/B test metrics context (`ab_tests`) kept for backward compatibility.
* Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }).
*/
abTests?: Record<string, string>;
/**
* New A/B test metrics context (`active_ab_tests`) that replaces `ab_tests`.
* Kept separate so migration can run both payloads in parallel.
* This field is an array of test objects.
*/
activeAbTests?: { key: string; value: string }[];
/**
* Attempts tracking for exponential backoff on failed fetches.
* We track the number of attempts and the last attempt time for each txMetaId that has failed at least once
Expand Down Expand Up @@ -212,7 +218,10 @@ export type StartPollingForBridgeTxStatusArgs = {
approvalTxId?: BridgeHistoryItem['approvalTxId'];
isStxEnabled?: BridgeHistoryItem['isStxEnabled'];
location?: BridgeHistoryItem['location'];
// Legacy field for `ab_tests` metrics payload.
abTests?: BridgeHistoryItem['abTests'];
Copy link
Member

Choose a reason for hiding this comment

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

Will clients be using this field after they update to this controller version?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, assets team is still using this field for active tests. Will be deprecated as soon as those tests are complete.

// New field for `active_ab_tests` metrics payload.
activeAbTests?: BridgeHistoryItem['activeAbTests'];
accountAddress: string;
};

Expand Down
38 changes: 37 additions & 1 deletion packages/bridge-status-controller/src/utils/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { StatusTypes, FeeType, ActionTypes } from '@metamask/bridge-controller';
import {
StatusTypes,
FeeType,
ActionTypes,
MetaMetricsSwapsEventSource,
} from '@metamask/bridge-controller';
import {
MetricsSwapType,
MetricsActionType,
Expand All @@ -17,6 +22,7 @@ import {
getTradeDataFromHistory,
getRequestMetadataFromHistory,
getEVMTxPropertiesFromTransactionMeta,
getPreConfirmationPropertiesFromQuote,
} from './metrics';
import type { BridgeHistoryItem } from '../types';

Expand Down Expand Up @@ -964,6 +970,36 @@ describe('metrics utils', () => {
});
});

describe('getPreConfirmationPropertiesFromQuote', () => {
it('should include both ab_tests and active_ab_tests when both sets are provided', () => {
const abTests = { token_details_layout: 'treatment' };
const activeAbTests = [
{ key: 'bridge_quote_sorting', value: 'variant_b' },
];
const result = getPreConfirmationPropertiesFromQuote(
{
quote: mockHistoryItem.quote,
estimatedProcessingTimeInSeconds: 900,
adjustedReturn: { usd: '1980' },
sentAmount: { usd: '2000' },
gasFee: { effective: { usd: '2.54739' } },
} as never,
false,
false,
MetaMetricsSwapsEventSource.MainView,
abTests,
activeAbTests,
);

expect(result).toStrictEqual(
expect.objectContaining({
ab_tests: abTests,
active_ab_tests: activeAbTests,
}),
);
});
});

describe('getEVMSwapTxPropertiesFromTransactionMeta', () => {
const mockTransactionMeta: TransactionMeta = {
id: 'test-tx-id',
Expand Down
13 changes: 11 additions & 2 deletions packages/bridge-status-controller/src/utils/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ export const getPriceImpactFromQuote = (
* @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension
* @param isHardwareAccount - whether the tx is submitted using a hardware wallet
* @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore)
* @param abTests - A/B test context to attribute events to specific experiments
* @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility)
* @param activeAbTests - New A/B test context for `active_ab_tests` (migration target)
* @returns The properties for the pre-confirmation event
*/
export const getPreConfirmationPropertiesFromQuote = (
Expand All @@ -186,6 +187,7 @@ export const getPreConfirmationPropertiesFromQuote = (
isHardwareAccount: boolean,
location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView,
abTests?: Record<string, string>,
activeAbTests?: { key: string; value: string }[],
) => {
const { quote } = quoteResponse;
return {
Expand All @@ -205,7 +207,14 @@ export const getPreConfirmationPropertiesFromQuote = (
action_type: MetricsActionType.SWAPBRIDGE_V1,
custom_slippage: false, // TODO detect whether the user changed the default slippage
location,
...(abTests && Object.keys(abTests).length > 0 && { ab_tests: abTests }),
...(abTests &&
Object.keys(abTests).length > 0 && {
ab_tests: abTests,
}),
...(activeAbTests &&
activeAbTests.length > 0 && {
active_ab_tests: activeAbTests,
}),
};
};

Expand Down
Loading