From 024bbce9a4658858ed47a4bbba6f69de1f58e7ec Mon Sep 17 00:00:00 2001 From: eddie <66155195+just-toby@users.noreply.github.com> Date: Thu, 17 Aug 2023 10:16:51 -0700 Subject: [PATCH] feat: more swap metric logging (#7173) * wip: more metrics * wip: SWAP_INPUT_FIRST_USED * feat: track elapsed times * feat: add e2e test * fix: order of logging * fix: nits --- cypress/e2e/swap/timeToSwap.test.ts | 4 ++ package.json | 2 +- src/analytics/index.tsx | 9 ++- src/pages/Swap/index.tsx | 20 +++++-- src/state/transactions/updater.tsx | 28 +-------- src/tracing/SwapEventTimestampTracker.test.ts | 50 ++++++++++++++++ src/tracing/SwapEventTimestampTracker.ts | 58 +++++++++++++++++++ src/tracing/swapFlowLoggers.ts | 34 +++++++++++ src/tracing/utils.ts | 8 +++ yarn.lock | 8 +-- 10 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 src/tracing/SwapEventTimestampTracker.test.ts create mode 100644 src/tracing/SwapEventTimestampTracker.ts create mode 100644 src/tracing/swapFlowLoggers.ts create mode 100644 src/tracing/utils.ts diff --git a/cypress/e2e/swap/timeToSwap.test.ts b/cypress/e2e/swap/timeToSwap.test.ts index 7d5c6c8be2..05234bb4e9 100644 --- a/cypress/e2e/swap/timeToSwap.test.ts +++ b/cypress/e2e/swap/timeToSwap.test.ts @@ -25,6 +25,9 @@ describe('time-to-swap logging', () => { cy.wrap(event.event_properties).should('have.property', 'time_to_swap') cy.wrap(event.event_properties.time_to_swap).should('be.a', 'number') cy.wrap(event.event_properties.time_to_swap).should('be.gte', 0) + cy.wrap(event.event_properties).should('have.property', 'time_to_swap_since_first_input') + cy.wrap(event.event_properties.time_to_swap_since_first_input).should('be.a', 'number') + cy.wrap(event.event_properties.time_to_swap_since_first_input).should('be.gte', 0) }) // Second swap in the session: @@ -40,6 +43,7 @@ describe('time-to-swap logging', () => { cy.get(getTestSelector('popups')).contains('Swapped') cy.waitForAmplitudeEvent(SwapEventName.SWAP_TRANSACTION_COMPLETED).then((event: any) => { cy.wrap(event.event_properties).should('not.have.property', 'time_to_swap') + cy.wrap(event.event_properties).should('not.have.property', 'time_to_swap_since_first_input') }) }) }) diff --git a/package.json b/package.json index e91ef53798..26464d36ea 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "@sentry/types": "^7.45.0", "@types/react-window-infinite-loader": "^1.0.6", "@uniswap/analytics": "^1.4.0", - "@uniswap/analytics-events": "^2.15.0", + "@uniswap/analytics-events": "^2.17.0", "@uniswap/conedison": "^1.8.0", "@uniswap/governance": "^1.0.2", "@uniswap/liquidity-staker": "^1.0.2", diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 56a88602bc..f5030b13e9 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -6,7 +6,14 @@ import { import { atomWithStorage, useAtomValue } from 'jotai/utils' import { memo } from 'react' -export { getDeviceId, initializeAnalytics, OriginApplication, user, useTrace } from '@uniswap/analytics' +export { + type ITraceContext, + getDeviceId, + initializeAnalytics, + OriginApplication, + user, + useTrace, +} from '@uniswap/analytics' const allowAnalyticsAtomKey = 'allow_analytics' export const allowAnalyticsAtom = atomWithStorage(allowAnalyticsAtomKey, true) diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index f2a1b26a0e..a6b8c8ef7b 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -55,6 +55,7 @@ import { useDefaultsFromURLSearch, useDerivedSwapInfo, useSwapActionHandlers } f import swapReducer, { initialState as initialSwapState, SwapState } from 'state/swap/reducer' import styled, { useTheme } from 'styled-components' import { LinkStyledButton, ThemedText } from 'theme' +import { maybeLogFirstSwapAction } from 'tracing/swapFlowLoggers' import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact' import { formatCurrencyAmount, NumberType } from 'utils/formatNumbers' import { maxAmountSpend } from 'utils/maxAmountSpend' @@ -328,14 +329,16 @@ export function Swap({ const handleTypeInput = useCallback( (value: string) => { onUserInput(Field.INPUT, value) + maybeLogFirstSwapAction(trace) }, - [onUserInput] + [onUserInput, trace] ) const handleTypeOutput = useCallback( (value: string) => { onUserInput(Field.OUTPUT, value) + maybeLogFirstSwapAction(trace) }, - [onUserInput] + [onUserInput, trace] ) const navigate = useNavigate() @@ -498,13 +501,15 @@ export function Swap({ }, [Field.OUTPUT]: state[Field.OUTPUT], }) + maybeLogFirstSwapAction(trace) }, - [onCurrencyChange, onCurrencySelection, state] + [onCurrencyChange, onCurrencySelection, state, trace] ) const handleMaxInput = useCallback(() => { maxInputAmount && onUserInput(Field.INPUT, maxInputAmount.toExact()) - }, [maxInputAmount, onUserInput]) + maybeLogFirstSwapAction(trace) + }, [maxInputAmount, onUserInput, trace]) const handleOutputSelect = useCallback( (outputCurrency: Currency) => { @@ -515,8 +520,9 @@ export function Swap({ currencyId: getSwapCurrencyId(outputCurrency), }, }) + maybeLogFirstSwapAction(trace) }, - [onCurrencyChange, onCurrencySelection, state] + [onCurrencyChange, onCurrencySelection, state, trace] ) const showPriceImpactWarning = isClassicTrade(trade) && largerPriceImpact && priceImpactSeverity > 3 @@ -608,7 +614,9 @@ export function Swap({ { - !disableTokenInputs && onSwitchTokens() + if (disableTokenInputs) return + onSwitchTokens() + maybeLogFirstSwapAction(trace) }} color={theme.textPrimary} > diff --git a/src/state/transactions/updater.tsx b/src/state/transactions/updater.tsx index 4ddaa277e6..4ba73c86a3 100644 --- a/src/state/transactions/updater.tsx +++ b/src/state/transactions/updater.tsx @@ -1,12 +1,12 @@ import { TransactionReceipt } from '@ethersproject/abstract-provider' -import { SwapEventName } from '@uniswap/analytics-events' import { useWeb3React } from '@web3-react/core' -import { sendAnalyticsEvent, useTrace } from 'analytics' +import { useTrace } from 'analytics' import { DEFAULT_TXN_DISMISS_MS, L2_TXN_DISMISS_MS } from 'constants/misc' import LibUpdater from 'lib/hooks/transactions/updater' import { useCallback, useMemo } from 'react' import { PopupType } from 'state/application/reducer' import { useAppDispatch, useAppSelector } from 'state/hooks' +import { logSwapSuccess } from 'tracing/swapFlowLoggers' import { L2_CHAIN_IDS } from '../../constants/chains' import { useAddPopup } from '../application/hooks' @@ -27,18 +27,6 @@ export function toSerializableReceipt(receipt: TransactionReceipt): Serializable } } -// We only log the time-to-swap metric for the first swap of a session. -let hasReportedTimeToSwap = false - -/** - * Returns the time elapsed between page load and now, - * if the time-to-swap mark doesn't already exist. - */ -function getElapsedTime(): number { - const timeToSwap = performance.mark('time-to-swap') - return timeToSwap.startTime -} - export default function Updater() { const analyticsContext = useTrace() const { chainId } = useWeb3React() @@ -70,17 +58,7 @@ export default function Updater() { }) ) - const elapsedTime = getElapsedTime() - - sendAnalyticsEvent(SwapEventName.SWAP_TRANSACTION_COMPLETED, { - // if timeToSwap was already set, we already logged this session - time_to_swap: hasReportedTimeToSwap ? undefined : elapsedTime, - chainId, - hash, - ...analyticsContext, - }) - - hasReportedTimeToSwap = true + logSwapSuccess(hash, chainId, analyticsContext) addPopup( { diff --git a/src/tracing/SwapEventTimestampTracker.test.ts b/src/tracing/SwapEventTimestampTracker.test.ts new file mode 100644 index 0000000000..7f5854d1ea --- /dev/null +++ b/src/tracing/SwapEventTimestampTracker.test.ts @@ -0,0 +1,50 @@ +import { SwapEventTimestampTracker, SwapEventType } from './SwapEventTimestampTracker' + +jest.mock('./utils', () => ({ + calculateElapsedTimeWithPerformanceMark: (mark: string) => { + switch (mark) { + case SwapEventType.FIRST_SWAP_ACTION: + return 100 + case SwapEventType.FIRST_QUOTE_FETCH_STARTED: + return 200 + case SwapEventType.FIRST_SWAP_SIGNATURE_REQUESTED: + return 300 + case SwapEventType.FIRST_SWAP_SIGNATURE_COMPLETED: + return 400 + case SwapEventType.FIRST_SWAP_SUCCESS: + return 500 + default: + return 0 + } + }, +})) + +describe('SwapEventTimestampTracker', () => { + it('should create a new instance', () => { + const instance = SwapEventTimestampTracker.getInstance() + expect(instance).toBeInstanceOf(SwapEventTimestampTracker) + }) + + it('should return the same instance', () => { + const instance1 = SwapEventTimestampTracker.getInstance() + const instance2 = SwapEventTimestampTracker.getInstance() + expect(instance1).toBe(instance2) + }) + + it('should set and get elapsed time', () => { + const instance = SwapEventTimestampTracker.getInstance() + expect(instance.setElapsedTime(SwapEventType.FIRST_SWAP_SUCCESS)).toEqual(500) + expect(instance.getElapsedTime(SwapEventType.FIRST_SWAP_SUCCESS)).toEqual(500) + }) + + it('should get elapsed time between two events', () => { + const instance = SwapEventTimestampTracker.getInstance() + expect(instance.setElapsedTime(SwapEventType.FIRST_SWAP_ACTION)).toEqual(100) + expect(instance.getElapsedTime(SwapEventType.FIRST_SWAP_SUCCESS, SwapEventType.FIRST_SWAP_ACTION)).toEqual(400) + }) + + it('should return undefined if event type not set', () => { + const instance = SwapEventTimestampTracker.getInstance() + expect(instance.getElapsedTime(SwapEventType.FIRST_QUOTE_FETCH_STARTED)).toBeUndefined() + }) +}) diff --git a/src/tracing/SwapEventTimestampTracker.ts b/src/tracing/SwapEventTimestampTracker.ts new file mode 100644 index 0000000000..103594233e --- /dev/null +++ b/src/tracing/SwapEventTimestampTracker.ts @@ -0,0 +1,58 @@ +import { calculateElapsedTimeWithPerformanceMark } from './utils' + +export enum SwapEventType { + /** + * Full list of actions that can trigger the FIRST_SWAP_ACTION moment: + * - “max” clicked for an input amount + * - token selected (input or output) + * - token amount typed (input or output) + * - reverse button clicked + */ + FIRST_SWAP_ACTION = 'FIRST_SWAP_ACTION', + FIRST_QUOTE_FETCH_STARTED = 'FIRST_QUOTE_FETCH_STARTED', + FIRST_SWAP_SIGNATURE_REQUESTED = 'FIRST_SWAP_SIGNATURE_REQUESTED', + FIRST_SWAP_SIGNATURE_COMPLETED = 'FIRST_SWAP_SIGNATURE_COMPLETED', + FIRST_SWAP_SUCCESS = 'FIRST_SWAP_SUCCESS', +} + +export class SwapEventTimestampTracker { + private static _instance: SwapEventTimestampTracker + private constructor() { + // Private constructor to prevent direct construction calls with the `new` operator. + } + public static getInstance(): SwapEventTimestampTracker { + if (!this._instance) { + this._instance = new SwapEventTimestampTracker() + } + return this._instance + } + + private timestamps: Map = new Map() + + public hasTimestamp(eventType: SwapEventType): boolean { + return this.timestamps.has(eventType) + } + + public setElapsedTime(eventType: SwapEventType): number | undefined { + if (this.timestamps.has(eventType)) return undefined + const elapsedTime = calculateElapsedTimeWithPerformanceMark(eventType) + if (elapsedTime) { + this.timestamps.set(eventType, elapsedTime) + } + return this.timestamps.get(eventType) + } + + /** + * Returns the time elapsed between the given event and the start event, + * or page load if the start event is not provided. + */ + public getElapsedTime(eventType: SwapEventType, startEventType?: SwapEventType): number | undefined { + const endTime = this.timestamps.get(eventType) + if (!endTime) return undefined + let startTime = 0 + if (startEventType) { + startTime = this.timestamps.get(startEventType) ?? 0 + } + return endTime - startTime + } +} diff --git a/src/tracing/swapFlowLoggers.ts b/src/tracing/swapFlowLoggers.ts new file mode 100644 index 0000000000..380623bc8f --- /dev/null +++ b/src/tracing/swapFlowLoggers.ts @@ -0,0 +1,34 @@ +import { SwapEventName } from '@uniswap/analytics-events' +import { ITraceContext } from 'analytics' +import { sendAnalyticsEvent } from 'analytics' + +import { SwapEventTimestampTracker, SwapEventType } from './SwapEventTimestampTracker' + +const tracker = SwapEventTimestampTracker.getInstance() + +export function logSwapSuccess(hash: string, chainId: number, analyticsContext: ITraceContext) { + const hasSetSwapSuccess = tracker.hasTimestamp(SwapEventType.FIRST_SWAP_SUCCESS) + const elapsedTime = tracker.setElapsedTime(SwapEventType.FIRST_SWAP_SUCCESS) + sendAnalyticsEvent(SwapEventName.SWAP_TRANSACTION_COMPLETED, { + // We only log the time-to-swap metric for the first swap of a session, + // so if it was previously set we log undefined here. + time_to_swap: hasSetSwapSuccess ? undefined : elapsedTime, + time_to_swap_since_first_input: hasSetSwapSuccess + ? undefined + : tracker.getElapsedTime(SwapEventType.FIRST_SWAP_SUCCESS, SwapEventType.FIRST_SWAP_ACTION), + hash, + chainId, + ...analyticsContext, + }) +} + +// We only log the time-to-first-swap-input metric for the first swap input of a session. +export function maybeLogFirstSwapAction(analyticsContext: ITraceContext) { + if (!tracker.hasTimestamp(SwapEventType.FIRST_SWAP_ACTION)) { + const elapsedTime = tracker.setElapsedTime(SwapEventType.FIRST_SWAP_ACTION) + sendAnalyticsEvent(SwapEventName.SWAP_FIRST_ACTION, { + time_to_first_swap_action: elapsedTime, + ...analyticsContext, + }) + } +} diff --git a/src/tracing/utils.ts b/src/tracing/utils.ts new file mode 100644 index 0000000000..a8ca39651d --- /dev/null +++ b/src/tracing/utils.ts @@ -0,0 +1,8 @@ +/** + * Returns the time elapsed between page load and now. + * @param markName the identifier for the performance mark to be created and measured. + */ +export function calculateElapsedTimeWithPerformanceMark(markName: string): number { + const elapsedTime = performance.mark(markName) + return elapsedTime.startTime +} diff --git a/yarn.lock b/yarn.lock index a03a208e6f..2766ba6153 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6173,10 +6173,10 @@ "@typescript-eslint/types" "5.59.1" eslint-visitor-keys "^3.3.0" -"@uniswap/analytics-events@^2.15.0": - version "2.15.0" - resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.15.0.tgz#46de9832a2b0b0d8a08e67c6502c327d74f122f1" - integrity sha512-hRYa4YzX/NNLcSgwpkJ5Ich1swFGNftGkfS7nU41XliVbqhDqNSCnAdeHu8mFOx/W8xzf4cb773bYk001nY4mQ== +"@uniswap/analytics-events@^2.17.0": + version "2.17.0" + resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.17.0.tgz#cc0fab2737a673b740ba6d303f04eab365b1876e" + integrity sha512-8obHdI+YjfuFCcPH7XOqIDxYVMl30GSUBMZjTx9Ik5vZAXNFJpyOjBcb0aZbL5L6y3vzqkCR8HUpAgd3NuOBFA== "@uniswap/analytics@^1.4.0": version "1.4.0"