diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 880226bda77..1c716a5c3f9 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Sentry quote-fetch and provider first-result performance traces ([#9899](https://github.com/MetaMask/core/pull/9899)) + ### Changed - Bump `@metamask/assets-controller` from `^13.1.2` to `^13.1.4` ([#9873](https://github.com/MetaMask/core/pull/9873), [#9886](https://github.com/MetaMask/core/pull/9886)) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index f0ebc848edd..2c412553bf3 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -1,5 +1,6 @@ import { BigNumber } from '@ethersproject/bignumber'; import * as ethersContractUtils from '@ethersproject/contracts'; +import type { TraceRequest } from '@metamask/controller-utils'; import { SolScope } from '@metamask/keyring-api'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -38,6 +39,7 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, ETH_USDT_ADDRESS, } from './constants/bridge.js'; +import { TraceName } from './constants/traces.js'; import { ChainId, RequestStatus } from './types.js'; import type { BridgeControllerMessenger } from './types.js'; import * as balanceUtils from './utils/balance.js'; @@ -47,12 +49,17 @@ import { } from './utils/caip-formatters.js'; import * as featureFlagUtils from './utils/feature-flags.js'; import * as fetchUtils from './utils/fetch.js'; +import { AbortReason } from './utils/metrics/constants.js'; import { FeatureId } from './validators/feature-flags.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete.js'; import { TokenFeatureType } from './validators/token-feature.js'; import type { TxData } from './validators/trade.js'; +jest.mock('uuid', () => ({ + v4: (): string => 'test-uuid-1234', +})); + type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, @@ -104,6 +111,16 @@ const metricsContext = { token_security_type_destination: null, }; +const createTraceCallback = (traceRequests: TraceRequest[]): jest.Mock => + jest + .fn() + .mockImplementation( + async (request: TraceRequest, callback?: () => unknown) => { + traceRequests.push(request); + return await callback?.(); + }, + ); + const assetExchangeRates = { 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { exchangeRate: undefined, @@ -242,6 +259,149 @@ describe('BridgeController SSE', function () { jest.resetAllMocks(); }); + describe('quote tracing', () => { + const runTraceScenario = async ({ + response, + request = quoteRequest, + abort = false, + }: { + response: () => unknown; + request?: typeof quoteRequest; + abort?: boolean; + }): Promise => { + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ controller, rootMessenger }) => { + mockFetchFn.mockImplementationOnce(async () => response()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + request, + metricsContext, + ); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + if (abort) { + controller.stopPollingForQuotes(AbortReason.NewQuoteRequest); + } + jest.advanceTimersByTime(11000); + await flushPromises(); + }, + ); + + return traceRequests; + }; + + const getTrace = ( + requests: TraceRequest[], + name: TraceName, + ): TraceRequest | undefined => + requests.find((request) => request.name === name); + + it('records cross-chain success and the first result from each provider', async () => { + const firstQuote = mockBridgeQuotesErc20Erc20V1[0]; + const secondProviderQuote = { + ...firstQuote, + quote: { + ...firstQuote.quote, + requestId: 'second-provider-request', + bridgeId: 'hop', + bridges: ['hop'], + protocols: ['hop'], + }, + }; + + const requests = await runTraceScenario({ + response: (): unknown => + mockSseEventSource([firstQuote, firstQuote, secondProviderQuote]), + }); + const providerTraces = requests.filter( + (request) => request.name === TraceName.QuoteProviderFirstResult, + ); + + expect( + getTrace(requests, TraceName.BridgeQuotesFetched)?.data, + ).toStrictEqual( + expect.objectContaining({ + request_id: 'test-uuid-1234', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + result: 'success', + }), + ); + expect( + providerTraces.map((request) => request.data?.provider), + ).toStrictEqual(['socket_across', 'hop_hop']); + expect( + providerTraces.every((request) => Number.isFinite(request.startTime)), + ).toBe(true); + }); + + it.each([ + { + name: 'same-chain success', + response: (): unknown => + mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]]), + request: { + ...quoteRequest, + destChainId: quoteRequest.srcChainId, + }, + traceName: TraceName.SwapQuotesFetched, + result: 'success', + providerCount: 1, + }, + { + name: 'no quotes', + response: (): unknown => mockSseEventSource([]), + request: quoteRequest, + traceName: TraceName.BridgeQuotesFetched, + result: 'no_quotes', + providerCount: 0, + }, + { + name: 'error', + response: (): unknown => mockSseServerError('provider request failed'), + request: quoteRequest, + traceName: TraceName.BridgeQuotesFetched, + result: 'error', + providerCount: 0, + }, + ])('records $name', async (scenario) => { + const requests = await runTraceScenario(scenario); + + expect(getTrace(requests, scenario.traceName)?.data).toStrictEqual( + expect.objectContaining({ + result: scenario.result, + }), + ); + expect( + requests.filter( + (request) => request.name === TraceName.QuoteProviderFirstResult, + ), + ).toHaveLength(scenario.providerCount); + }); + + it('records cancellation for an expected abort', async () => { + const requests = await runTraceScenario({ + response: (): unknown => + mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]], 10000), + abort: true, + }); + + expect( + getTrace(requests, TraceName.BridgeQuotesFetched)?.data, + ).toStrictEqual( + expect.objectContaining({ + result: 'cancelled', + }), + ); + }); + }); + it('should trigger quote polling if request is valid', async function () { await withController( async ({ diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index ce390808bf7..4e9146b425d 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2180,8 +2180,7 @@ describe('BridgeController', function () { ); // Trigger the fetch + abort rejection - jest.advanceTimersByTime(1000); - await flushPromises(); + await jest.advanceTimersByTimeAsync(1000); // Early return path: no post-fetch updates expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ce3dcbb4be4..62f27167288 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -3,12 +3,13 @@ import { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { StateMetadata } from '@metamask/base-controller'; -import type { TraceCallback } from '@metamask/controller-utils'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionController } from '@metamask/transaction-controller'; import type { CaipAssetType, Hex } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; import type { BridgeClientId } from './constants/bridge.js'; @@ -72,6 +73,7 @@ import { formatProviderLabel, getAccountHardwareType, getRequestParams, + getSwapType, getSwapTypeFromQuote, isCustomSlippage, toInputChangedPropertyKey, @@ -200,6 +202,31 @@ type BridgePollingInput = { RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesRequested]; }; +type QuoteTraceResult = 'success' | 'cancelled' | 'no_quotes' | 'error'; + +const QUOTE_ABORT_REASONS = new Set(Object.values(AbortReason)); + +const isExpectedQuoteAbort = ( + error: unknown, + signal?: AbortSignal, +): boolean => { + if (signal?.aborted) { + return true; + } + + if (QUOTE_ABORT_REASONS.has(String(error))) { + return true; + } + + const errorText = + error instanceof Error ? `${error.name} ${error.message}` : String(error); + + return ( + errorText.includes('AbortError') || + errorText.includes('FetchRequestCanceledException') + ); +}; + const MESSENGER_EXPOSED_METHODS = [ 'updateBridgeQuoteRequestParams', 'fetchQuotes', @@ -791,6 +818,9 @@ export class BridgeController extends StaticIntervalPollingController console.warn('Failed to fetch asset exchange rates', error), @@ -823,83 +853,110 @@ export class BridgeController extends StaticIntervalPollingController { - const selectedAccount = this.#getMultichainSelectedAccount( - firstQuoteRequest.walletAddress, - ); - // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#setMinimumBalanceForRentExemptionInLamports( + } + const tracedProviders = new Set(); + const traceWithoutImpact = async (request: TraceRequest): Promise => { + try { + await this.#trace(request, () => undefined); + } catch { + // Telemetry failures must not affect quote fetching or state updates. + } + }; + const traceProviderFirstResult = ( + providerData: Parameters[0], + ) => { + const provider = formatProviderLabel(providerData); + if (isBatchSellRequest || tracedProviders.has(provider)) { + return; + } + tracedProviders.add(provider); + // Provider telemetry must not delay quote processing. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + traceWithoutImpact({ + name: TraceName.QuoteProviderFirstResult, + startTime: quoteTraceStartTime, + data: { + provider, + feature_id: context.feature_id, + request_id: quoteTraceRequestId, + swap_type: getSwapType( firstQuoteRequest.srcChainId, - selectedAccount?.metadata?.snap?.id, - ); - // Use SSE if enabled and return early - if (shouldStream || isBatchSellRequest) { - await this.#handleQuoteStreaming( - quoteRequests, - context.feature_id, - jwt, - selectedAccount, - ); - return; - } - // Otherwise use regular fetch - const quotes = await this.fetchQuotes( - firstQuoteRequest, - context.feature_id, - this.#abortController?.signal, - ); - this.update((state) => { - // Set the initial load time if this is the first fetch - if ( - state.quotesRefreshCount === - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && - this.#quotesFirstFetched - ) { - state.quotesInitialLoadTime = - Date.now() - this.#quotesFirstFetched; - } - state.quotes = quotes.map(toQuoteResponseV2); - state.quotesLoadingStatus = RequestStatus.FETCHED; - }); + firstQuoteRequest.destChainId, + ), + srcChainId: formatChainIdToCaip(firstQuoteRequest.srcChainId), + destChainId: formatChainIdToCaip(firstQuoteRequest.destChainId), + result: 'success', }, + }); + }; + + try { + const selectedAccount = this.#getMultichainSelectedAccount( + firstQuoteRequest.walletAddress, ); + // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#setMinimumBalanceForRentExemptionInLamports( + firstQuoteRequest.srcChainId, + selectedAccount?.metadata?.snap?.id, + ); + // Use SSE if enabled and return early + if (shouldStream || isBatchSellRequest) { + const quoteCount = await this.#handleQuoteStreaming({ + quoteRequests, + featureId: context.feature_id, + jwt, + selectedAccount, + signal: quoteAbortSignal, + traceProviderFirstResult, + }); + if (quoteAbortSignal.aborted) { + traceResult = 'cancelled'; + return; + } + traceResult = quoteCount > 0 ? 'success' : 'no_quotes'; + } else { + // Otherwise use regular fetch + const quotes = await this.fetchQuotes( + firstQuoteRequest, + context.feature_id, + quoteAbortSignal, + ); + for (const quote of quotes) { + traceProviderFirstResult(quote.quote); + } + this.update((state) => { + // Set the initial load time if this is the first fetch + if ( + state.quotesRefreshCount === + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && + this.#quotesFirstFetched + ) { + state.quotesInitialLoadTime = Date.now() - this.#quotesFirstFetched; + } + state.quotes = quotes.map(toQuoteResponseV2); + state.quotesLoadingStatus = RequestStatus.FETCHED; + }); + traceResult = quotes.length > 0 ? 'success' : 'no_quotes'; + } } catch (error) { // Reset the quotes list if the fetch fails to avoid showing stale quotes this.update((state) => { state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; }); // Ignore abort errors - if ( - (error as Error).toString().includes('AbortError') || - (error as Error).toString().includes('FetchRequestCanceledException') || - [ - AbortReason.ResetState, - AbortReason.NewQuoteRequest, - AbortReason.QuoteRequestUpdated, - AbortReason.TransactionSubmitted, - ].includes(error as AbortReason) - ) { + if (isExpectedQuoteAbort(error, quoteAbortSignal)) { + traceResult = 'cancelled'; // Exit the function early to prevent other state updates return; } @@ -928,6 +985,20 @@ export class BridgeController extends StaticIntervalPollingController { + readonly #handleQuoteStreaming = async ({ + quoteRequests, + featureId, + jwt, + selectedAccount, + signal, + traceProviderFirstResult, + }: { + quoteRequests: GenericQuoteRequest[]; + featureId: FeatureId; + jwt?: string; + selectedAccount?: InternalAccount; + signal?: AbortSignal; + traceProviderFirstResult: ( + providerData: Parameters[0], + ) => void; + }): Promise => { /** * Tracks the number of valid quotes received from the current stream, which is used * to determine when to clear the quotes list and set the initial load time @@ -970,7 +1052,7 @@ export class BridgeController extends StaticIntervalPollingController 0) { validQuotesCounter += 1; + traceProviderFirstResult(quote.quote); } this.update((state) => { // Clear previous quotes and quotes load time when first quote in the current @@ -1052,6 +1135,8 @@ export class BridgeController extends StaticIntervalPollingController + jest + .fn() + .mockImplementation( + async (request: TraceRequest, callback?: () => unknown) => { + traceRequests.push(request); + return await callback?.(); + }, + ); + +const getSwapOperationCompletedTrace = ( + traceRequests: TraceRequest[], +): TraceRequest | undefined => + traceRequests.find(({ name }) => name === TraceName.SwapOperationCompleted); + function getRootMessenger(): RootMessenger { return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } @@ -1770,6 +1787,157 @@ describe('BridgeStatusController', () => { }); }); + describe('swap operation completion tracing', () => { + it.each([ + { + name: 'success', + result: 'success', + response: (): StatusResponse => MockStatusResponse.getComplete(), + destinationTxHash: '0xdestTxHash1', + }, + { + name: 'failure', + result: 'error', + response: (): StatusResponse => MockStatusResponse.getFailed(), + destinationTxHash: undefined, + }, + ])('records $name', async (scenario) => { + jest.useFakeTimers(); + const startTime = 1729964825189; + const completionTime = 1736277625746; + jest.spyOn(Date, 'now').mockImplementation(() => completionTime); + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValueOnce({ + status: scenario.response(), + validationFailures: [], + }); + + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + const trace = getSwapOperationCompletedTrace(traceRequests); + expect(trace).toStrictEqual( + expect.objectContaining({ + name: TraceName.SwapOperationCompleted, + startTime, + data: expect.objectContaining({ + srcChainId: 'eip155:42161', + destChainId: 'eip155:10', + provider: 'lifi_across', + swap_type: 'crosschain', + terminal_stage: 'destination', + quote_id: '197c402f-cb96-4096-9f8c-54aed84ca776', + transaction_id: 'bridgeTxMetaId1', + src_tx_hash: '0xsrcTxHash1', + result: scenario.result, + }), + }), + ); + expect(trace?.data?.dest_tx_hash ?? null).toBe( + scenario.destinationTxHash ?? null, + ); + expect( + traceRequests.filter( + ({ name }) => name === TraceName.SwapOperationCompleted, + ), + ).toHaveLength(1); + }, + ); + }); + + it.each([ + { + name: 'same-chain success', + history: () => MockTxHistory.getPendingSwap(), + transactionId: 'swapTxMetaId1', + transactionType: TransactionType.swap, + transactionStatus: TransactionStatus.confirmed, + result: 'success', + swapType: 'single_chain', + }, + { + name: 'cross-chain source failure', + history: () => MockTxHistory.getPending(), + transactionId: 'bridgeTxMetaId1', + transactionType: TransactionType.bridge, + transactionStatus: TransactionStatus.failed, + result: 'error', + swapType: 'crosschain', + }, + ])( + 'records $name', + async ({ + history, + transactionId, + transactionType, + transactionStatus, + result, + swapType, + }) => { + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + state: { + txHistory: history(), + }, + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + hash: '0xsourceTxHash', + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: transactionType, + status: transactionStatus, + id: transactionId, + } as TransactionMeta, + }, + ); + await flushPromises(); + + expect( + getSwapOperationCompletedTrace(traceRequests), + ).toStrictEqual( + expect.objectContaining({ + name: TraceName.SwapOperationCompleted, + data: expect.objectContaining({ + result, + swap_type: swapType, + terminal_stage: 'source', + transaction_id: transactionId, + }), + }), + ); + }, + ); + }, + ); + }); + it.each([ { status: TransactionStatus.confirmed, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index a877b3d0862..581976d1c81 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -99,7 +99,14 @@ import { getPreConfirmationPropertiesFromQuote, } from './utils/metrics.js'; import { getSelectedChainId } from './utils/network.js'; -import { getTraceParams } from './utils/trace.js'; +import { + getSwapOperationCompletedTraceParams, + getTraceParams, +} from './utils/trace.js'; +import type { + SwapOperationResult, + SwapOperationTerminalStage, +} from './utils/trace.js'; import { getTransactionMetaById, getTransactions, @@ -339,6 +346,36 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + if (!historyKey) { + return; + } + + const historyItem = this.state.txHistory[historyKey]; + const featureId = historyItem?.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE; + if ( + !historyItem || + historyItem.batchSellData || + !ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS.includes(featureId) + ) { + return; + } + + await this.#trace( + getSwapOperationCompletedTraceParams( + historyItem, + historyKey, + result, + terminalStage, + ), + () => undefined, + ); + }; + readonly #onTransactionFailed = ({ txMeta, historyKey, @@ -348,10 +385,12 @@ export class BridgeStatusController extends StaticIntervalPollingController { - // Check if the history item is already marked as a failure const isHistoryItemAlreadyFailed = historyKey ? this.state.txHistory[historyKey]?.status.status === StatusTypes.FAILED : false; + const isIntent = historyKey + ? Boolean(this.state.txHistory[historyKey]?.quote.intent) + : false; this.#updateHistoryItem({ historyKey, @@ -364,12 +403,16 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined, + ); + } + // Report finalized failure for swap/bridge transactions. // Note: TransactionStatus.rejected means the user cancelled signing, so the tx was never broadcast. // `hasNestedSwapTransactions` also covers batch/7702 swaps whose type may @@ -413,6 +456,9 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); + } this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Completed, historyKey, @@ -1102,6 +1155,17 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); + if (status.status === StatusTypes.COMPLETE) { this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Completed, @@ -1330,6 +1394,11 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); this.#quoteStatusManager.reportFinalised( payload.historyKey, true, diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts index c194658e892..da2696b42b6 100644 --- a/packages/bridge-status-controller/src/constants.ts +++ b/packages/bridge-status-controller/src/constants.ts @@ -23,6 +23,8 @@ export enum TraceName { BridgeTransactionCompleted = 'Bridge Transaction Completed', SwapTransactionApprovalCompleted = 'Swap Transaction Approval Completed', SwapTransactionCompleted = 'Swap Transaction Completed', + // For this constant only, "Swap" is the umbrella term for single-chain and cross-chain operations; use `swap_type` to distinguish them. + SwapOperationCompleted = 'Swap Operation Completed', } export const ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS = [ diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts index 570cd46cf3a..95800264e92 100644 --- a/packages/bridge-status-controller/src/utils/trace.ts +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -1,11 +1,18 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { formatChainIdToCaip, + formatProviderLabel, + FeatureId, + getSwapType, isCrossChain, QuoteResponseV1, } from '@metamask/bridge-controller'; import { TraceName } from '../constants.js'; +import type { BridgeHistoryItem } from '../types.js'; + +export type SwapOperationResult = 'success' | 'error'; +export type SwapOperationTerminalStage = 'source' | 'destination'; export const getTraceParams = ( quoteResponse: QuoteResponseV1, @@ -21,6 +28,7 @@ export const getTraceParams = ( data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), stxEnabled: isStxEnabled, + feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, }, }; }; @@ -39,6 +47,41 @@ export const getApprovalTraceParams = ( data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), stxEnabled: isStxEnabled, + feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + }, + }; +}; + +export const getSwapOperationCompletedTraceParams = ( + historyItem: BridgeHistoryItem, + historyKey: string, + result: SwapOperationResult, + terminalStage: SwapOperationTerminalStage, +) => { + const quoteId = historyItem.quoteId ?? historyItem.quote.requestId; + const sourceTransactionHash = historyItem.status.srcChain.txHash; + const destinationTransactionHash = historyItem.status.destChain?.txHash; + + return { + name: TraceName.SwapOperationCompleted, + startTime: historyItem.startTime, + data: { + srcChainId: formatChainIdToCaip(historyItem.quote.srcChainId), + destChainId: formatChainIdToCaip(historyItem.quote.destChainId), + feature_id: historyItem.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + provider: formatProviderLabel(historyItem.quote), + swap_type: getSwapType( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ), + terminal_stage: terminalStage, + transaction_id: historyItem.txMetaId ?? historyKey, + result, + ...(quoteId ? { quote_id: quoteId } : {}), + ...(sourceTransactionHash ? { src_tx_hash: sourceTransactionHash } : {}), + ...(destinationTransactionHash + ? { dest_tx_hash: destinationTransactionHash } + : {}), }, }; };