feat: widget analytics (#4869)

* chore: todos for analytics

* example

* another skeleton logging event

* feat: onSwapApprove

* feat: widget tracing

* feat: better useTrace

* feat: max

* feat: switch

* add trace amd remove onreviewswapclick

* onExpandSwapDetails

* feat: initial quote

* add event properties for wrap

* feat: update ack

* SWAP_SIGNED

* feat: submit

* fix: wrap type

* chore: tracing

* move format fn to utils

* fix: remove old background-color flash (#4890)

remove old background-color

* revert: add back phase0 bug fixes (#4888)

* Revert "revert: removing phase0 bug fixes temporarily (#4886)"

This reverts commit 06291a15a6af77b8493e14e2cb2c84cea5c10709.

* use token amount

* Revert "use token amount"

This reverts commit f47c00358b29cfa9886bf4e490df4bf70b88e648.

* dont render if empty

* fix: upgrade pkg to eliminate compile error (#4898)

* upgrade pkg

* dedup

* fix: Remove token selector flash of old ui  (#4896)

remove token selector flash of old view

* fix: Web 1561 logging event for clicking on explore banner toast +  WEB-1543  [Explore Banner] String should be sentence case (#4899)

* explore banner changes

* remove console log

* oops

* test: run tests on all PRs (#4905)

* fix: use correct optimism icon in explore (#4893)

* fix: click area should match button effect (#4887)

* fix: update font-weight values to match spec (#4863)

* fixes. working now verified on console.

* fix: mobile tweaks (#4910)

* update manifest theme colors to magenta

* text spacing and right positioning on very small screens

* feat: load token from query data (#4904)

* fix: do not fetch balances cross-chain

* build: updgrade widget

* feat: cleanly load and switch chains from widget

* fix: load token from query data

* build: trigger checks

* fix: do not override native token from query

* fix: catch error on switch chain

* refactor: useTokenFromActiveNetwork

* refactor: defaultToken behavior clarification

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
Co-authored-by: Jordan Frankfurt <jordanwfrankfurt@gmail.com>
Co-authored-by: vignesh mohankumar <vignesh@vigneshmohankumar.com>
Co-authored-by: cartcrom <39385577+cartcrom@users.noreply.github.com>
This commit is contained in:
lynn 2022-10-13 12:20:59 -04:00 committed by GitHub
parent cdd5b66d1b
commit a5c5567936
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 218 additions and 80 deletions

@ -20,6 +20,11 @@ export interface ITraceContext {
export const TraceContext = createContext<ITraceContext>({})
export function useTrace(trace?: ITraceContext): ITraceContext {
const parentTrace = useContext(TraceContext)
return useMemo(() => ({ ...parentTrace, ...trace }), [parentTrace, trace])
}
type TraceProps = {
shouldLogImpression?: boolean // whether to log impression on mount
name?: EventName
@ -31,15 +36,24 @@ type TraceProps = {
* and propagates the context to child traces.
*/
export const Trace = memo(
({ shouldLogImpression, name, children, page, section, element, properties }: PropsWithChildren<TraceProps>) => {
const parentTrace = useContext(TraceContext)
({
shouldLogImpression,
name,
children,
page,
section,
modal,
element,
properties,
}: PropsWithChildren<TraceProps>) => {
const parentTrace = useTrace()
const combinedProps = useMemo(
() => ({
...parentTrace,
...Object.fromEntries(Object.entries({ page, section, element }).filter(([_, v]) => v !== undefined)),
...Object.fromEntries(Object.entries({ page, section, modal, element }).filter(([_, v]) => v !== undefined)),
}),
[element, parentTrace, page, section]
[element, parentTrace, page, modal, section]
)
useEffect(() => {

@ -89,6 +89,7 @@ export enum PageName {
export enum SectionName {
CURRENCY_INPUT_PANEL = 'swap-currency-input',
CURRENCY_OUTPUT_PANEL = 'swap-currency-output',
WIDGET = 'widget',
// alphabetize additional section names.
}

@ -1,12 +1,16 @@
import { Currency, CurrencyAmount, Percent, Token } from '@uniswap/sdk-core'
import { Trade } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, Percent, Price, Token, TradeType } from '@uniswap/sdk-core'
import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { InterfaceTrade } from 'state/routing/types'
import { computeRealizedPriceImpact } from 'utils/prices'
export const getDurationUntilTimestampSeconds = (futureTimestampInSecondsSinceEpoch?: number): number | undefined => {
if (!futureTimestampInSecondsSinceEpoch) return undefined
return futureTimestampInSecondsSinceEpoch - new Date().getTime() / 1000
}
export const getDurationFromDateMilliseconds = (start: Date): number => {
export const getDurationFromDateMilliseconds = (start?: Date): number | undefined => {
if (!start) return undefined
return new Date().getTime() - start.getTime()
}
@ -20,3 +24,57 @@ export const getTokenAddress = (currency: Currency) => (currency.isNative ? NATI
export const formatPercentInBasisPointsNumber = (percent: Percent): number => parseFloat(percent.toFixed(2)) * 100
export const formatPercentNumber = (percent: Percent): number => parseFloat(percent.toFixed(2))
export const getPriceUpdateBasisPoints = (
prevPrice: Price<Currency, Currency>,
newPrice: Price<Currency, Currency>
): number => {
const changeFraction = newPrice.subtract(prevPrice).divide(prevPrice)
const changePercentage = new Percent(changeFraction.numerator, changeFraction.denominator)
return formatPercentInBasisPointsNumber(changePercentage)
}
export const formatSwapSignedAnalyticsEventProperties = ({
trade,
txHash,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType> | Trade<Currency, Currency, TradeType>
txHash: string
}) => ({
transaction_hash: txHash,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
})
export const formatSwapQuoteReceivedEventProperties = (
trade: Trade<Currency, Currency, TradeType>,
gasUseEstimateUSD?: CurrencyAmount<Token>,
fetchingSwapQuoteStartTime?: Date
) => {
return {
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
price_impact_basis_points: trade ? formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)) : undefined,
estimated_network_fee_usd: gasUseEstimateUSD ? formatToDecimal(gasUseEstimateUSD, 2) : undefined,
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
quote_latency_milliseconds: fetchingSwapQuoteStartTime
? getDurationFromDateMilliseconds(fetchingSwapQuoteStartTime)
: undefined,
}
}

@ -2,19 +2,31 @@
// eslint-disable-next-line no-restricted-imports
import '@uniswap/widgets/dist/fonts.css'
import { Trade } from '@uniswap/router-sdk'
import { Currency, TradeType } from '@uniswap/sdk-core'
import {
AddEthereumChainParameter,
Currency,
EMPTY_TOKEN_LIST,
OnReviewSwapClick,
SwapWidget,
SwapWidgetSkeleton,
} from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core'
import { sendAnalyticsEvent } from 'analytics'
import { EventName, SectionName } from 'analytics/constants'
import { SWAP_PRICE_UPDATE_USER_RESPONSE } from 'analytics/constants'
import { useTrace } from 'analytics/Trace'
import {
formatPercentInBasisPointsNumber,
formatToDecimal,
getPriceUpdateBasisPoints,
getTokenAddress,
} from 'analytics/utils'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { useCallback } from 'react'
import { useIsDarkMode } from 'state/user/hooks'
import { DARK_THEME, LIGHT_THEME } from 'theme/widget'
import { computeRealizedPriceImpact } from 'utils/prices'
import { switchChain } from 'utils/switchChain'
import { useSyncWidgetInputs } from './inputs'
@ -39,6 +51,71 @@ export default function Widget({ defaultToken, onReviewSwapClick }: WidgetProps)
const { settings } = useSyncWidgetSettings()
const { transactions } = useSyncWidgetTransactions()
const trace = useTrace({ section: SectionName.WIDGET })
// TODO(lynnshaoyu): add back onInitialSwapQuote logging once widget side logic is fixed
// in onInitialSwapQuote handler.
const onApproveToken = useCallback(() => {
const input = inputs.value.INPUT
if (!input) return
const eventProperties = {
chain_id: input.chainId,
token_symbol: input.symbol,
token_address: getTokenAddress(input),
...trace,
}
sendAnalyticsEvent(EventName.APPROVE_TOKEN_TXN_SUBMITTED, eventProperties)
}, [inputs.value.INPUT, trace])
const onExpandSwapDetails = useCallback(() => {
sendAnalyticsEvent(EventName.SWAP_DETAILS_EXPANDED, { ...trace })
}, [trace])
const onSwapPriceUpdateAck = useCallback(
(stale: Trade<Currency, Currency, TradeType>, update: Trade<Currency, Currency, TradeType>) => {
const eventProperties = {
chain_id: update.inputAmount.currency.chainId,
response: SWAP_PRICE_UPDATE_USER_RESPONSE.ACCEPTED,
token_in_symbol: update.inputAmount.currency.symbol,
token_out_symbol: update.outputAmount.currency.symbol,
price_update_basis_points: getPriceUpdateBasisPoints(stale.executionPrice, update.executionPrice),
...trace,
}
sendAnalyticsEvent(EventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED, eventProperties)
},
[trace]
)
const onSubmitSwapClick = useCallback(
(trade: Trade<Currency, Currency, TradeType>) => {
const eventProperties = {
// TODO(1416): Include undefined values.
estimated_network_fee_usd: undefined,
transaction_deadline_seconds: undefined,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
token_in_amount_usd: undefined,
token_out_amount_usd: undefined,
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
allowed_slippage_basis_points: undefined,
is_auto_router_api: undefined,
is_auto_slippage: undefined,
chain_id: trade.inputAmount.currency.chainId,
// duration should be getDurationFromDateMilliseconds(initialQuoteDate) once initialQuoteDate
// is made available from TODO above for onInitialSwapQuote logging.
duration_from_first_quote_to_swap_submission_milliseconds: undefined,
swap_quote_block_number: undefined,
...trace,
}
sendAnalyticsEvent(EventName.SWAP_SUBMITTED_BUTTON_CLICKED, eventProperties)
},
[trace]
)
const onSwitchChain = useCallback(
// TODO: Widget should not break if this rejects - upstream the catch to ignore it.
({ chainId }: AddEthereumChainParameter) => switchChain(connector, Number(chainId)).catch(() => undefined),
@ -58,7 +135,6 @@ export default function Widget({ defaultToken, onReviewSwapClick }: WidgetProps)
width={WIDGET_WIDTH}
locale={locale}
theme={theme}
onReviewSwapClick={onReviewSwapClick}
// defaultChainId is excluded - it is always inferred from the passed provider
provider={provider}
onSwitchChain={onSwitchChain}
@ -66,6 +142,11 @@ export default function Widget({ defaultToken, onReviewSwapClick }: WidgetProps)
{...inputs}
{...settings}
{...transactions}
onExpandSwapDetails={onExpandSwapDetails}
onReviewSwapClick={onReviewSwapClick}
onSubmitSwapClick={onSubmitSwapClick}
onSwapApprove={onApproveToken}
onSwapPriceUpdateAck={onSwapPriceUpdateAck}
/>
{tokenSelector}
</>

@ -1,4 +1,7 @@
import { Currency, Field, SwapController, SwapEventHandlers, TradeType } from '@uniswap/widgets'
import { sendAnalyticsEvent } from 'analytics'
import { EventName, SectionName } from 'analytics/constants'
import { useTrace } from 'analytics/Trace'
import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -9,12 +12,20 @@ const EMPTY_AMOUNT = ''
* Treats the Widget as a controlled component, using the app's own token selector for selection.
*/
export function useSyncWidgetInputs(defaultToken?: Currency) {
const trace = useTrace({ section: SectionName.WIDGET })
const [type, setType] = useState(TradeType.EXACT_INPUT)
const [amount, setAmount] = useState(EMPTY_AMOUNT)
const onAmountChange = useCallback((field: Field, amount: string) => {
setType(toTradeType(field))
setAmount(amount)
}, [])
const onAmountChange = useCallback(
(field: Field, amount: string, origin?: 'max') => {
if (origin === 'max') {
sendAnalyticsEvent(EventName.SWAP_MAX_TOKEN_AMOUNT_SELECTED, { ...trace })
}
setType(toTradeType(field))
setAmount(amount)
},
[trace]
)
const [tokens, setTokens] = useState<{ [Field.INPUT]?: Currency; [Field.OUTPUT]?: Currency }>({
[Field.OUTPUT]: defaultToken,
@ -30,12 +41,13 @@ export function useSyncWidgetInputs(defaultToken?: Currency) {
}, [defaultToken])
const onSwitchTokens = useCallback(() => {
sendAnalyticsEvent(EventName.SWAP_TOKENS_REVERSED, { ...trace })
setType((type) => invertTradeType(type))
setTokens((tokens) => ({
[Field.INPUT]: tokens[Field.OUTPUT],
[Field.OUTPUT]: tokens[Field.INPUT],
}))
}, [])
}, [trace])
const [selectingField, setSelectingField] = useState<Field>()
const otherField = useMemo(() => (selectingField === Field.INPUT ? Field.OUTPUT : Field.INPUT), [selectingField])

@ -6,6 +6,12 @@ import {
TransactionType as WidgetTransactionType,
} from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core'
import { sendAnalyticsEvent } from 'analytics'
import { EventName, SectionName } from 'analytics/constants'
import { useTrace } from 'analytics/Trace'
import { formatToDecimal, getTokenAddress } from 'analytics/utils'
import { formatSwapSignedAnalyticsEventProperties } from 'analytics/utils'
import { WrapType } from 'hooks/useWrapCallback'
import { useCallback, useMemo } from 'react'
import { useTransactionAdder } from 'state/transactions/hooks'
import {
@ -18,6 +24,8 @@ import { currencyId } from 'utils/currencyId'
/** Integrates the Widget's transactions, showing the widget's transactions in the app. */
export function useSyncWidgetTransactions() {
const trace = useTrace({ section: SectionName.WIDGET })
const { chainId } = useWeb3React()
const addTransaction = useTransactionAdder()
@ -28,8 +36,23 @@ export function useSyncWidgetTransactions() {
if (!type || !response) {
return
} else if (type === WidgetTransactionType.WRAP || type === WidgetTransactionType.UNWRAP) {
const { amount } = transaction.info
const { type, amount: transactionAmount } = transaction.info
const eventProperties = {
// get this info from widget handlers
token_in_address: getTokenAddress(transactionAmount.currency),
token_out_address: getTokenAddress(transactionAmount.currency.wrapped),
token_in_symbol: transactionAmount.currency.symbol,
token_out_symbol: transactionAmount.currency.wrapped.symbol,
chain_id: transactionAmount.currency.chainId,
amount: transactionAmount
? formatToDecimal(transactionAmount, transactionAmount?.currency.decimals)
: undefined,
type: type === WidgetTransactionType.WRAP ? WrapType.WRAP : WrapType.UNWRAP,
...trace,
}
sendAnalyticsEvent(EventName.WRAP_TOKEN_TXN_SUBMITTED, eventProperties)
const { amount } = transaction.info
addTransaction(response, {
type: AppTransactionType.WRAP,
unwrapped: type === WidgetTransactionType.UNWRAP,
@ -38,6 +61,15 @@ export function useSyncWidgetTransactions() {
} as WrapTransactionInfo)
} else if (type === WidgetTransactionType.SWAP) {
const { slippageTolerance, trade, tradeType } = transaction.info
const eventProperties = {
...formatSwapSignedAnalyticsEventProperties({
trade,
txHash: transaction.receipt?.transactionHash ?? '',
}),
...trace,
}
sendAnalyticsEvent(EventName.SWAP_SIGNED, eventProperties)
const baseTxInfo = {
type: AppTransactionType.SWAP,
tradeType,
@ -61,7 +93,7 @@ export function useSyncWidgetTransactions() {
}
}
},
[addTransaction, chainId]
[addTransaction, chainId, trace]
)
const txHandlers: TransactionEventHandlers = useMemo(() => ({ onTxSubmit }), [onTxSubmit])

@ -5,10 +5,9 @@ import { sendAnalyticsEvent } from 'analytics'
import { ModalName } from 'analytics/constants'
import { EventName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
import { formatPercentInBasisPointsNumber, formatToDecimal, getTokenAddress } from 'analytics/utils'
import { formatSwapSignedAnalyticsEventProperties } from 'analytics/utils'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import { computeRealizedPriceImpact } from 'utils/prices'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import TransactionConfirmationModal, {
@ -18,27 +17,6 @@ import TransactionConfirmationModal, {
import SwapModalFooter from './SwapModalFooter'
import SwapModalHeader from './SwapModalHeader'
const formatAnalyticsEventProperties = ({
trade,
txHash,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
txHash: string
}) => ({
transaction_hash: txHash,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
})
export default function ConfirmSwapModal({
trade,
originalTrade,
@ -149,7 +127,7 @@ export default function ConfirmSwapModal({
useEffect(() => {
if (!attemptingTxn && isOpen && txHash && trade && txHash !== lastTxnHashLogged) {
sendAnalyticsEvent(EventName.SWAP_SIGNED, formatAnalyticsEventProperties({ trade, txHash }))
sendAnalyticsEvent(EventName.SWAP_SIGNED, formatSwapSignedAnalyticsEventProperties({ trade, txHash }))
setLastTxnHashLogged(txHash)
}
}, [attemptingTxn, isOpen, txHash, trade, lastTxnHashLogged])

@ -1,9 +1,8 @@
import { Trans } from '@lingui/macro'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Price } from '@uniswap/sdk-core'
import { sendAnalyticsEvent } from 'analytics'
import { EventName, SWAP_PRICE_UPDATE_USER_RESPONSE } from 'analytics/constants'
import { formatPercentInBasisPointsNumber } from 'analytics/utils'
import { getPriceUpdateBasisPoints } from 'analytics/utils'
import { RedesignVariant, useRedesignFlag } from 'featureFlags/flags/redesign'
import { useEffect, useState } from 'react'
import { AlertTriangle, ArrowDown } from 'react-feather'
@ -58,15 +57,6 @@ const formatAnalyticsEventProperties = (
price_update_basis_points: priceUpdate,
})
const getPriceUpdateBasisPoints = (
prevPrice: Price<Currency, Currency>,
newPrice: Price<Currency, Currency>
): number => {
const changeFraction = newPrice.subtract(prevPrice).divide(prevPrice)
const changePercentage = new Percent(changeFraction.numerator, changeFraction.denominator)
return formatPercentInBasisPointsNumber(changePercentage)
}
export default function SwapModalHeader({
trade,
shouldLogModalCloseEvent,

@ -6,12 +6,7 @@ import { sendAnalyticsEvent } from 'analytics'
import { ElementName, Event, EventName, PageName, SectionName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
import { TraceEvent } from 'analytics/TraceEvent'
import {
formatPercentInBasisPointsNumber,
formatToDecimal,
getDurationFromDateMilliseconds,
getTokenAddress,
} from 'analytics/utils'
import { formatSwapQuoteReceivedEventProperties } from 'analytics/utils'
import { sendEvent } from 'components/analytics'
import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert'
import PriceImpactWarning from 'components/swap/PriceImpactWarning'
@ -153,29 +148,6 @@ function largerPercentValue(a?: Percent, b?: Percent) {
return undefined
}
const formatSwapQuoteReceivedEventProperties = (
trade: InterfaceTrade<Currency, Currency, TradeType>,
fetchingSwapQuoteStartTime: Date | undefined
) => {
return {
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
price_impact_basis_points: trade ? formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)) : undefined,
estimated_network_fee_usd: trade.gasUseEstimateUSD ? formatToDecimal(trade.gasUseEstimateUSD, 2) : undefined,
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
quote_latency_milliseconds: fetchingSwapQuoteStartTime
? getDurationFromDateMilliseconds(fetchingSwapQuoteStartTime)
: undefined,
}
}
const TRADE_STRING = 'SwapRouter'
export default function Swap() {
@ -510,7 +482,7 @@ export default function Swap() {
// Log swap quote.
sendAnalyticsEvent(
EventName.SWAP_QUOTE_RECEIVED,
formatSwapQuoteReceivedEventProperties(trade, fetchingSwapQuoteStartTime)
formatSwapQuoteReceivedEventProperties(trade, trade.gasUseEstimateUSD ?? undefined, fetchingSwapQuoteStartTime)
)
// Latest swap quote has just been logged, so we don't need to log the current trade anymore
// unless user inputs change again and a new trade is in the process of being generated.