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:
parent
c960c14170
commit
024bbce9a4
@ -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(
|
||||
{
|
||||
|
50
src/tracing/SwapEventTimestampTracker.test.ts
Normal file
50
src/tracing/SwapEventTimestampTracker.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
58
src/tracing/SwapEventTimestampTracker.ts
Normal file
58
src/tracing/SwapEventTimestampTracker.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
34
src/tracing/swapFlowLoggers.ts
Normal file
34
src/tracing/swapFlowLoggers.ts
Normal file
@ -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
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"
|
||||
|
Loading…
Reference in New Issue
Block a user