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
This commit is contained in:
eddie 2023-08-17 10:16:51 -07:00 committed by GitHub
parent c960c14170
commit 024bbce9a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 184 additions and 37 deletions

@ -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')
})
})
})

@ -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",

@ -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<boolean>(allowAnalyticsAtomKey, true)

@ -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({
<ArrowContainer
data-testid="swap-currency-button"
onClick={() => {
!disableTokenInputs && onSwitchTokens()
if (disableTokenInputs) return
onSwitchTokens()
maybeLogFirstSwapAction(trace)
}}
color={theme.textPrimary}
>

@ -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(
{

@ -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()
})
})

@ -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<SwapEventType, number | undefined> = 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
}
}

@ -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,
})
}
}

8
src/tracing/utils.ts Normal file

@ -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
}

@ -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"