From f9b029b281e186398d5f5393d5a2aeb33c5518dd Mon Sep 17 00:00:00 2001 From: Tarik Bellamine <40568900+tarikbellamine@users.noreply.github.com> Date: Thu, 11 Jun 2020 11:13:25 -0700 Subject: [PATCH] [Wallet] Analytics Retooling + Analytics for Send & Request (#4041) ### Description Created ability to add event properties for subEvent tracking and exposed those methods to the component used in the wallet. Added analytics for the 2nd half of the Send flow (Ivan already did the first half), Secure Send, and the Request Payment flow. ### Other changes Minor cleanup of dead code. ### Tested No new tests added. ### Related issues #3645 & #4008 ### Backwards compatibility Yes --- .../__mocks__/react-native-reanimated.js | 1 + packages/mobile/src/account/Education.tsx | 2 +- .../mobile/src/analytics/CeloAnalytics.ts | 31 ++- packages/mobile/src/analytics/constants.ts | 204 ++++++++++-------- packages/mobile/src/app/App.tsx | 2 +- packages/mobile/src/exchange/saga.ts | 4 +- packages/mobile/src/firebase/firebase.ts | 5 +- packages/mobile/src/identity/saga.ts | 16 +- packages/mobile/src/navigator/Navigator.tsx | 97 ++------- packages/mobile/src/navigator/types.tsx | 3 + .../PaymentRequestConfirmation.tsx | 19 +- .../PaymentRequestUnavailable.tsx | 17 +- .../PaymentRequestUnavailable.test.tsx.snap | 2 +- packages/mobile/src/qrcode/utils.ts | 7 +- packages/mobile/src/send/Send.tsx | 63 ++++-- packages/mobile/src/send/SendAmount.tsx | 28 ++- packages/mobile/src/send/SendConfirmation.tsx | 45 ++-- .../src/send/ValidateRecipientAccount.tsx | 29 ++- .../src/send/ValidateRecipientIntro.tsx | 12 ++ packages/mobile/src/send/saga.ts | 11 +- .../analytics/CeloAnalytics.test.tsx | 34 +-- .../analytics/CeloAnalytics.tsx | 57 +++-- 22 files changed, 431 insertions(+), 258 deletions(-) create mode 100644 packages/mobile/__mocks__/react-native-reanimated.js diff --git a/packages/mobile/__mocks__/react-native-reanimated.js b/packages/mobile/__mocks__/react-native-reanimated.js new file mode 100644 index 00000000000..ec88e413ef7 --- /dev/null +++ b/packages/mobile/__mocks__/react-native-reanimated.js @@ -0,0 +1 @@ +module.exports = require('react-native-reanimated/mock') diff --git a/packages/mobile/src/account/Education.tsx b/packages/mobile/src/account/Education.tsx index c8f2cbc1fa5..267d27366c9 100644 --- a/packages/mobile/src/account/Education.tsx +++ b/packages/mobile/src/account/Education.tsx @@ -84,7 +84,7 @@ class Education extends React.Component { return ( - {/* + {/* // @ts-ignore */} ( ) -const sendScreenOptions = ({ route }: { route: RouteProp }) => { - const goQr = () => navigate(Screens.QRNavigator) - return { - ...emptyHeader, - headerLeft: () => ( - } - onPress={navigateBack} - eventName={ - route.params?.isRequest ? CustomEventNames.send_cancel : CustomEventNames.request_cancel - } - /> - ), - headerLeftContainerStyle: { paddingLeft: 20 }, - headerRight: () => ( - } - eventName={ - route.params?.isRequest ? CustomEventNames.send_scan : CustomEventNames.request_scan - } - onPress={goQr} - /> - ), - headerRightContainerStyle: { paddingRight: 16 }, - headerTitle: i18n.t(`sendFlow7:${route.params?.isRequest ? 'request' : 'send'}`), - } -} - -const sendAmountScreenOptions = ({ - route, -}: { - route: RouteProp -}) => { - return { - ...emptyHeader, - headerLeft: () => , - headerTitle: () => ( - - ), - } -} - const emptyWithBackButtonHeaderOption = () => ({ ...emptyHeader, headerLeft: () => , }) -const navigateHome = () => navigate(Screens.WalletHome) - -const paymentRequestUnavailableScreenOptions = ({ - route, -}: { - route: RouteProp -}) => ({ - ...emptyHeader, - headerLeft: () => } onPress={navigateHome} />, - headerLeftContainerStyle: { paddingLeft: 20 }, -}) - const sendScreens = (Navigator: typeof Stack) => ( <> - + ({ + ...emptyHeader, + headerLeft: () => , +}) + class PaymentRequestConfirmation extends React.Component { state = { comment: '', @@ -91,11 +97,6 @@ class PaymentRequestConfirmation extends React.Component { onConfirm = async () => { const { amount, recipient, recipientAddress: requesteeAddress } = this.props.confirmationInput - - CeloAnalytics.track(CustomEventNames.request_payment_request, { - requesteeAddress, - }) - const { t } = this.props if (!recipient || (!recipient.e164PhoneNumber && !recipient.address)) { throw new Error("Can't request from recipient without valid e164 number or a wallet address") @@ -124,15 +125,11 @@ class PaymentRequestConfirmation extends React.Component { notified: false, } + CeloAnalytics.track(CustomEventNames.request_confirm, { requesteeAddress }) this.props.writePaymentRequest(paymentInfo) Logger.showMessage(t('requestSent')) } - onPressEdit = () => { - CeloAnalytics.track(CustomEventNames.request_payment_edit) - navigateBack() - } - renderFooter = () => { const amount = { value: this.props.confirmationInput.amount, diff --git a/packages/mobile/src/paymentRequest/PaymentRequestUnavailable.tsx b/packages/mobile/src/paymentRequest/PaymentRequestUnavailable.tsx index 496d0ab52b4..25b79f49fc7 100644 --- a/packages/mobile/src/paymentRequest/PaymentRequestUnavailable.tsx +++ b/packages/mobile/src/paymentRequest/PaymentRequestUnavailable.tsx @@ -1,3 +1,4 @@ +import Times from '@celo/react-components/icons/Times' import colors from '@celo/react-components/styles/colors.v2' import fontStyles from '@celo/react-components/styles/fonts.v2' import { StackScreenProps } from '@react-navigation/stack' @@ -5,12 +6,23 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { SafeAreaView, ScrollView, StyleSheet, Text } from 'react-native' import { Namespaces } from 'src/i18n' +import { emptyHeader } from 'src/navigator/Headers.v2' +import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' +import { TopBarIconButton } from 'src/navigator/TopBarButton.v2' import { StackParamList } from 'src/navigator/types' type RouteProps = StackScreenProps type Props = RouteProps +const navigateHome = () => navigate(Screens.WalletHome) + +export const paymentRequestUnavailableScreenNavOptions = () => ({ + ...emptyHeader, + headerLeft: () => } onPress={navigateHome} />, + headerLeftContainerStyle: styles.headerContainer, +}) + const PaymentRequestUnavailable = (props: Props) => { const { t } = useTranslation(Namespaces.paymentRequestFlow) const { recipient } = props.route.params.transactionData @@ -45,6 +57,9 @@ const styles = StyleSheet.create({ paddingVertical: 32, justifyContent: 'flex-start', }, + headerContainer: { + paddingLeft: 16, + }, requestHeader: { ...fontStyles.h2, paddingVertical: 20, @@ -54,7 +69,7 @@ const styles = StyleSheet.create({ body: { ...fontStyles.regular, textAlign: 'center', - paddingBottom: 20, + paddingBottom: 24, }, }) diff --git a/packages/mobile/src/paymentRequest/__snapshots__/PaymentRequestUnavailable.test.tsx.snap b/packages/mobile/src/paymentRequest/__snapshots__/PaymentRequestUnavailable.test.tsx.snap index a398fcf065a..787c41e41dc 100644 --- a/packages/mobile/src/paymentRequest/__snapshots__/PaymentRequestUnavailable.test.tsx.snap +++ b/packages/mobile/src/paymentRequest/__snapshots__/PaymentRequestUnavailable.test.tsx.snap @@ -43,7 +43,7 @@ exports[`renders correctly 1`] = ` "fontFamily": "Inter-Regular", "fontSize": 16, "lineHeight": 22, - "paddingBottom": 20, + "paddingBottom": 24, "textAlign": "center", } } diff --git a/packages/mobile/src/qrcode/utils.ts b/packages/mobile/src/qrcode/utils.ts index 337e58cfded..0086cab1ee1 100644 --- a/packages/mobile/src/qrcode/utils.ts +++ b/packages/mobile/src/qrcode/utils.ts @@ -4,6 +4,8 @@ import * as RNFS from 'react-native-fs' import Share from 'react-native-share' import { call, put } from 'redux-saga/effects' import { showMessage } from 'src/alert/actions' +import CeloAnalytics from 'src/analytics/CeloAnalytics' +import { CustomEventNames } from 'src/analytics/constants' import { ErrorMessages } from 'src/app/ErrorMessages' import { validateRecipientAddressSuccess } from 'src/identity/actions' import { AddressToE164NumberType, E164NumberToAddressType } from 'src/identity/reducer' @@ -69,10 +71,13 @@ function* handleSecureSend( address.toLowerCase() ) if (!possibleRecievingAddressesFormatted.includes(userScannedAddress)) { - yield put(showMessage(ErrorMessages.QR_FAILED_INVALID_RECIPIENT)) + const error = ErrorMessages.QR_FAILED_INVALID_RECIPIENT + CeloAnalytics.track(CustomEventNames.send_secure_incorrect, { method: 'scan', error }) + yield put(showMessage(error)) return false } + CeloAnalytics.track(CustomEventNames.send_secure_success, { method: 'scan' }) yield put(validateRecipientAddressSuccess(e164PhoneNumber, userScannedAddress)) return true } diff --git a/packages/mobile/src/send/Send.tsx b/packages/mobile/src/send/Send.tsx index 8e4673b4fa6..df103d14cc8 100644 --- a/packages/mobile/src/send/Send.tsx +++ b/packages/mobile/src/send/Send.tsx @@ -1,5 +1,8 @@ +import QRCodeBorderlessIcon from '@celo/react-components/icons/QRCodeBorderless' +import Times from '@celo/react-components/icons/Times' import VerifyPhone from '@celo/react-components/icons/VerifyPhone' -import colors from '@celo/react-components/styles/colors' +import colors from '@celo/react-components/styles/colors.v2' +import { RouteProp } from '@react-navigation/native' import { StackScreenProps } from '@react-navigation/stack' import { throttle } from 'lodash' import * as React from 'react' @@ -11,12 +14,14 @@ import CeloAnalytics from 'src/analytics/CeloAnalytics' import { CustomEventNames } from 'src/analytics/constants' import { ErrorMessages } from 'src/app/ErrorMessages' import { estimateFee, FeeType } from 'src/fees/actions' -import { Namespaces, withTranslation } from 'src/i18n' +import i18n, { Namespaces, withTranslation } from 'src/i18n' import ContactPermission from 'src/icons/ContactPermission' import { importContacts } from 'src/identity/actions' import { ContactMatches } from 'src/identity/types' -import { navigate } from 'src/navigator/NavigationService' +import { emptyHeader } from 'src/navigator/Headers.v2' +import { navigate, navigateBack } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' +import { TopBarIconButton } from 'src/navigator/TopBarButton.v2' import { StackParamList } from 'src/navigator/types' import { filterRecipientFactory, @@ -90,6 +95,42 @@ const mapDispatchToProps = { estimateFee, } +export const sendScreenNavOptions = ({ + route, +}: { + route: RouteProp +}) => { + const goQr = () => navigate(Screens.QRNavigator) + const title = route.params?.isRequest + ? i18n.t('paymentRequestFlow:request') + : i18n.t('sendFlow7:send') + + return { + ...emptyHeader, + headerLeft: () => ( + } + onPress={navigateBack} + eventName={ + route.params?.isRequest ? CustomEventNames.send_cancel : CustomEventNames.request_cancel + } + /> + ), + headerLeftContainerStyle: styles.headerContainer, + headerRight: () => ( + } + eventName={ + route.params?.isRequest ? CustomEventNames.send_scan : CustomEventNames.request_scan + } + onPress={goQr} + /> + ), + headerRightContainerStyle: styles.headerContainer, + headerTitle: title, + } +} + class Send extends React.Component { throttledSearch!: (searchQuery: string) => void allRecipientsFilter!: FilterType @@ -265,7 +306,7 @@ class Send extends React.Component { return ( // Intentionally not using SafeAreaView here as RecipientPicker // needs fullscreen rendering - + { } } -const style = StyleSheet.create({ +const styles = StyleSheet.create({ body: { flex: 1, backgroundColor: colors.background, }, - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 20, - }, - icon: { - marginBottom: 20, - height: 60, - width: 60, + headerContainer: { + paddingLeft: 16, }, }) diff --git a/packages/mobile/src/send/SendAmount.tsx b/packages/mobile/src/send/SendAmount.tsx index f7a08ef6b13..2e47b2ef650 100644 --- a/packages/mobile/src/send/SendAmount.tsx +++ b/packages/mobile/src/send/SendAmount.tsx @@ -2,7 +2,9 @@ import Button, { BtnSizes, BtnTypes } from '@celo/react-components/components/Bu import NumberKeypad from '@celo/react-components/components/NumberKeypad' import fontStyles from '@celo/react-components/styles/fonts.v2' import variables from '@celo/react-components/styles/variables' +import { CURRENCY_ENUM } from '@celo/utils/src/currencies' import { parseInputAmount } from '@celo/utils/src/parsing' +import { RouteProp } from '@react-navigation/native' import { StackScreenProps } from '@react-navigation/stack' import BigNumber from 'bignumber.js' import * as React from 'react' @@ -16,13 +18,14 @@ import CeloAnalytics from 'src/analytics/CeloAnalytics' import { CustomEventNames } from 'src/analytics/constants' import { TokenTransactionType } from 'src/apollo/types' import { ErrorMessages } from 'src/app/ErrorMessages' +import BackButton from 'src/components/BackButton.v2' import { DAILY_PAYMENT_LIMIT_CUSD, DOLLAR_TRANSACTION_MIN_AMOUNT, NUMBER_INPUT_MAX_DECIMALS, } from 'src/config' import { getFeeEstimateDollars } from 'src/fees/selectors' -import { Namespaces } from 'src/i18n' +import i18n, { Namespaces } from 'src/i18n' import { fetchAddressesAndValidate } from 'src/identity/actions' import { AddressValidationType, @@ -41,6 +44,7 @@ import { getLocalCurrencyExchangeRate, getLocalCurrencySymbol, } from 'src/localCurrency/selectors' +import { emptyHeader, HeaderTitleWithBalance } from 'src/navigator/Headers.v2' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' @@ -65,6 +69,22 @@ type Props = RouteProps const { decimalSeparator } = getNumberFormatSettings() +export const sendAmountScreenNavOptions = ({ + route, +}: { + route: RouteProp +}) => { + const title = route.params?.isRequest + ? i18n.t('paymentRequestFlow:request') + : i18n.t('sendFlow7:send') + + return { + ...emptyHeader, + headerLeft: () => , + headerTitle: () => , + } +} + function SendAmount(props: Props) { const dispatch = useDispatch() @@ -235,10 +255,14 @@ function SendAmount(props: Props) { navigate(Screens.ValidateRecipientIntro, { transactionData, addressValidationType, + isFromScan: props.route.params?.isFromScan, }) } else { CeloAnalytics.track(CustomEventNames.send_continue, continueAnalyticsParams) - navigate(Screens.SendConfirmation, { transactionData }) + navigate(Screens.SendConfirmation, { + transactionData, + isFromScan: props.route.params?.isFromScan, + }) } }, [ recipientVerificationStatus, diff --git a/packages/mobile/src/send/SendConfirmation.tsx b/packages/mobile/src/send/SendConfirmation.tsx index 383d8656333..da337c25a6c 100644 --- a/packages/mobile/src/send/SendConfirmation.tsx +++ b/packages/mobile/src/send/SendConfirmation.tsx @@ -14,6 +14,7 @@ import { connect } from 'react-redux' import CeloAnalytics from 'src/analytics/CeloAnalytics' import { CustomEventNames } from 'src/analytics/constants' import { TokenTransactionType } from 'src/apollo/types' +import BackButton from 'src/components/BackButton.v2' import CommentTextInput from 'src/components/CommentTextInput' import CurrencyDisplay, { DisplayType, FormatType } from 'src/components/CurrencyDisplay' import FeeIcon from 'src/components/FeeIcon' @@ -33,7 +34,11 @@ import { AddressValidationType } from 'src/identity/reducer' import { getAddressValidationType, getSecureSendAddress } from 'src/identity/secureSend' import { InviteBy } from 'src/invite/actions' import { getInvitationVerificationFeeInDollars } from 'src/invite/saga' -import { navigate, navigateBack } from 'src/navigator/NavigationService' +import { LocalCurrencyCode } from 'src/localCurrency/consts' +import { convertDollarsToLocalAmount } from 'src/localCurrency/convert' +import { getLocalCurrencyCode, getLocalCurrencyExchangeRate } from 'src/localCurrency/selectors' +import { emptyHeader } from 'src/navigator/Headers.v2' +import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { getDisplayName, getRecipientThumbnail } from 'src/recipients/recipient' @@ -58,6 +63,8 @@ interface StateProps { addressValidationType: AddressValidationType validatedRecipientAddress?: string addressJustValidated?: boolean + localCurrencyCode: LocalCurrencyCode + localCurrencyExchangeRate?: string | null } interface DispatchProps { @@ -97,6 +104,8 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => { const addressValidationType = getAddressValidationType(recipient, secureSendPhoneNumberMapping) // Undefined or null means no addresses ever validated through secure send const validatedRecipientAddress = getSecureSendAddress(recipient, secureSendPhoneNumberMapping) + const localCurrencyCode = getLocalCurrencyCode(state) + const localCurrencyExchangeRate = getLocalCurrencyExchangeRate(state) return { account: currentAccountSelector(state), @@ -109,9 +118,16 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => { addressValidationType, validatedRecipientAddress, addressJustValidated, + localCurrencyCode, + localCurrencyExchangeRate, } } +export const sendConfirmationScreenNavOptions = () => ({ + ...emptyHeader, + headerLeft: () => , +}) + export class SendConfirmation extends React.Component { state = { modalVisible: false, @@ -148,6 +164,18 @@ export class SendConfirmation extends React.Component { const timestamp = Date.now() + CeloAnalytics.track(CustomEventNames.send_confirm, { + method: this.props.route.params?.isFromScan ? 'scan' : 'search', + localCurrencyExchangeRate: this.props.localCurrencyExchangeRate, + localCurrency: this.props.localCurrencyCode, + dollarAmount: amount, + localCurrencyAmount: convertDollarsToLocalAmount( + amount, + this.props.localCurrencyExchangeRate + ), + isInvite: !recipientAddress, + }) + this.props.sendPaymentOrInvite( amount, timestamp, @@ -161,26 +189,13 @@ export class SendConfirmation extends React.Component { onEditAddressClick = () => { const { transactionData, addressValidationType } = this.props + CeloAnalytics.track(CustomEventNames.send_secure_edit) navigate(Screens.ValidateRecipientIntro, { transactionData, addressValidationType, }) } - onEditClick = () => { - CeloAnalytics.track(CustomEventNames.edit_dollar_confirm) - navigateBack() - } - - onCancelClick = () => { - const { firebasePendingRequestUid } = this.props.confirmationInput - if (firebasePendingRequestUid) { - this.props.declinePaymentRequest(firebasePendingRequestUid) - } - Logger.showMessage(this.props.t('paymentRequestFlow:requestDeclined')) - navigateBack() - } - renderHeader = () => { const { t } = this.props const { type } = this.props.confirmationInput diff --git a/packages/mobile/src/send/ValidateRecipientAccount.tsx b/packages/mobile/src/send/ValidateRecipientAccount.tsx index 240ad3dc9e4..faf2f970732 100644 --- a/packages/mobile/src/send/ValidateRecipientAccount.tsx +++ b/packages/mobile/src/send/ValidateRecipientAccount.tsx @@ -10,8 +10,11 @@ import { WithTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' import SafeAreaView from 'react-native-safe-area-view' import { connect } from 'react-redux' +import CeloAnalytics from 'src/analytics/CeloAnalytics' +import { CustomEventNames } from 'src/analytics/constants' import { ErrorMessages } from 'src/app/ErrorMessages' import AccountNumberCard from 'src/components/AccountNumberCard' +import BackButton from 'src/components/BackButton.v2' import CodeRow, { CodeRowStatus } from 'src/components/CodeRow' import ErrorMessageInline from 'src/components/ErrorMessageInline' import Modal from 'src/components/Modal' @@ -21,6 +24,7 @@ import InfoIcon from 'src/icons/InfoIcon.v2' import MenuBurgerCard from 'src/icons/MenuBurgerCard' import { validateRecipientAddress } from 'src/identity/actions' import { AddressValidationType } from 'src/identity/reducer' +import { emptyHeader } from 'src/navigator/Headers.v2' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' @@ -72,6 +76,11 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => { } } +export const validateRecipientAccountScreenNavOptions = () => ({ + ...emptyHeader, + headerLeft: () => , +}) + export class ValidateRecipientAccount extends React.Component { state: State = { inputValue: '', @@ -85,7 +94,11 @@ export class ValidateRecipientAccount extends React.Component { if (isPaymentRequest) { navigate(Screens.PaymentRequestConfirmation, { transactionData }) } else { - navigate(Screens.SendConfirmation, { transactionData, addressJustValidated: true }) + navigate(Screens.SendConfirmation, { + transactionData, + addressJustValidated: true, + isFromScan: this.props.route.params?.isFromScan, + }) } } } @@ -97,6 +110,12 @@ export class ValidateRecipientAccount extends React.Component { addressValidationType === AddressValidationType.FULL ? inputValue : singleDigitInputValueArr.join('') + + CeloAnalytics.track(CustomEventNames.send_secure_submit, { + validationType: addressValidationType === AddressValidationType.FULL ? 'full' : 'partial', + address: inputToValidate, + }) + this.props.validateRecipientAddress(inputToValidate, addressValidationType, recipient) } @@ -111,6 +130,14 @@ export class ValidateRecipientAccount extends React.Component { } toggleModal = () => { + const validationType = + this.props.addressValidationType === AddressValidationType.FULL ? 'full' : 'partial' + if (this.state.isModalVisible) { + CeloAnalytics.track(CustomEventNames.send_secure_info, { validationType }) + } else { + CeloAnalytics.track(CustomEventNames.send_secure_info_dismissed, { validationType }) + } + this.setState({ isModalVisible: !this.state.isModalVisible }) } diff --git a/packages/mobile/src/send/ValidateRecipientIntro.tsx b/packages/mobile/src/send/ValidateRecipientIntro.tsx index 73bf7995e11..bc34db8a722 100644 --- a/packages/mobile/src/send/ValidateRecipientIntro.tsx +++ b/packages/mobile/src/send/ValidateRecipientIntro.tsx @@ -8,8 +8,12 @@ import { WithTranslation } from 'react-i18next' import { ScrollView, StyleSheet, Text, View } from 'react-native' import SafeAreaView from 'react-native-safe-area-view' import { connect } from 'react-redux' +import CeloAnalytics from 'src/analytics/CeloAnalytics' +import { CustomEventNames } from 'src/analytics/constants' +import CancelButton from 'src/components/CancelButton.v2' import { Namespaces, withTranslation } from 'src/i18n' import { AddressValidationType } from 'src/identity/reducer' +import { emptyHeader } from 'src/navigator/Headers.v2' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' @@ -40,8 +44,14 @@ const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => { } } +export const validateRecipientIntroScreenNavOptions = () => ({ + ...emptyHeader, + headerLeft: () => , +}) + class ValidateRecipientIntro extends React.Component { onPressScanCode = () => { + CeloAnalytics.track(CustomEventNames.send_secure_start, { method: 'scan' }) navigate(Screens.QRNavigator, { screen: Screens.QRScanner, params: { @@ -54,10 +64,12 @@ class ValidateRecipientIntro extends React.Component { onPressConfirmAccount = () => { const { addressValidationType, transactionData, isPaymentRequest } = this.props + CeloAnalytics.track(CustomEventNames.send_secure_start, { method: 'manual' }) navigate(Screens.ValidateRecipientAccount, { transactionData, addressValidationType, isPaymentRequest, + isFromScan: this.props.route.params?.isFromScan, }) } diff --git a/packages/mobile/src/send/saga.ts b/packages/mobile/src/send/saga.ts index 3bcc112333a..6d4df75abe0 100644 --- a/packages/mobile/src/send/saga.ts +++ b/packages/mobile/src/send/saga.ts @@ -145,12 +145,10 @@ function* sendPaymentOrInviteSaga({ inviteMethod, firebasePendingRequestUid, }: SendPaymentOrInviteAction) { + const isInvite = !recipientAddress try { - recipientAddress - ? CeloAnalytics.track(CustomEventNames.send_dollar_confirm) - : CeloAnalytics.track(CustomEventNames.send_invite) - if (!recipient || (!recipient.e164PhoneNumber && !recipient.address)) { - throw new Error("Can't send to recipient without valid e164 number or address") + if (!recipient?.e164PhoneNumber && !recipient?.address) { + throw new Error("Can't send to recipient without valid e164PhoneNumber or address") } const ownAddress: string = yield select(currentAccountSelector) @@ -177,9 +175,12 @@ function* sendPaymentOrInviteSaga({ if (firebasePendingRequestUid) { yield put(completePaymentRequest(firebasePendingRequestUid)) } + + CeloAnalytics.track(CustomEventNames.send_complete, { isInvite }) navigateHome() yield put(sendPaymentOrInviteSuccess()) } catch (e) { + CeloAnalytics.track(CustomEventNames.send_error, { isInvite, error: e }) yield put(showError(ErrorMessages.SEND_PAYMENT_FAILED)) yield put(sendPaymentOrInviteFailure()) } diff --git a/packages/react-components/analytics/CeloAnalytics.test.tsx b/packages/react-components/analytics/CeloAnalytics.test.tsx index 765dc7c1f3e..f6c2a2263c6 100644 --- a/packages/react-components/analytics/CeloAnalytics.test.tsx +++ b/packages/react-components/analytics/CeloAnalytics.test.tsx @@ -1,11 +1,14 @@ import CeloAnalytics, { AnalyzedApps } from '@celo/react-components/analytics/CeloAnalytics' import ReactNativeLogger from '@celo/react-components/services/ReactNativeLogger' -const c = new CeloAnalytics( - AnalyzedApps.Wallet, - ['navigation.state.routeName', 'title'], - new ReactNativeLogger() -) +enum MockPropertyWhitelist { + address = 'address', + validationType = 'validationType', + 'navigation.state.routeName' = 'navigation.state.routeName', + title = 'title', +} + +const c = new CeloAnalytics(AnalyzedApps.Wallet, MockPropertyWhitelist, new ReactNativeLogger()) jest.mock('@segment/analytics-react-native', () => undefined) jest.mock('@segment/analytics-react-native-firebase', () => undefined) @@ -36,17 +39,24 @@ it('tracks events with subEvents correctly', () => { Date.now = jest.fn(() => 1000) c.startTracking('mockEvent') Date.now = jest.fn(() => 2000) - c.trackSubEvent('mockEvent', 'step1') + c.trackSubEvent('mockEvent', 'step1', { address: '0xce10ce10ce10ce10ce10ce10ce10ce10ce10ce10' }) Date.now = jest.fn(() => 3500) - c.trackSubEvent('mockEvent', 'step2') + c.trackSubEvent('mockEvent', 'step2', { validationType: 0 }) Date.now = jest.fn(() => 4000) c.stopTracking('mockEvent') - expect(c.track).toHaveBeenCalledWith('mockEvent', { - step1: 1000, - step2: 1500, - __totalTime__: 3000, - }) + expect(c.track).toHaveBeenCalledWith( + 'mockEvent', + { + step1: 1000, + step2: 1500, + __endTracking__: 500, + __totalTime__: 3000, + address: '0xce10ce10ce10ce10ce10ce10ce10ce10ce10ce10', + validationType: 0, + }, + false + ) c.track = defaultTrackMethod Date.now = defaultDateNow diff --git a/packages/react-components/analytics/CeloAnalytics.tsx b/packages/react-components/analytics/CeloAnalytics.tsx index ddb3b04a409..a32f66a42fb 100644 --- a/packages/react-components/analytics/CeloAnalytics.tsx +++ b/packages/react-components/analytics/CeloAnalytics.tsx @@ -58,20 +58,25 @@ export enum AnalyzedApps { // Map of event name to map of subEvent name to timestamp // Using Map to maintain insertion order -type ActiveEvents = Map> +interface SubEventData { + timestamp: number + subEventProps: {} +} + +type ActiveEvents = Map> class CeloAnalytics { readonly appName: AnalyzedApps readonly apiKey: string | undefined readonly defaultTestnet: string | undefined - readonly propertyPathWhiteList: string[] + readonly propertyPathWhiteList: { [key: string]: any } readonly Logger: ReactNativeLogger readonly activeEvents: ActiveEvents = new Map() deviceInfo: any constructor( appName: AnalyzedApps, - propertyPathWhiteList: string[], + propertyPathWhiteList: { [key: string]: any }, Logger: ReactNativeLogger, apiKey?: string, defaultTestnet?: string @@ -99,7 +104,7 @@ class CeloAnalytics { return true } - track(eventName: string, eventProperties: {}, attachDeviceInfo = false) { + track(eventName: string, eventProperties = {}, attachDeviceInfo = false) { if (!this.isEnabled()) { this.Logger.info(TAG, `Analytics is disabled, not tracking event ${eventName}`) return @@ -116,56 +121,66 @@ class CeloAnalytics { _.set(props, 'device', this.deviceInfo) } Analytics.track(eventName, props).catch((err) => { - this.Logger.error(TAG, `Failed to tracking event ${eventName}`, err) + this.Logger.error(TAG, `Failed to track event ${eventName}`, err) }) } // Used with trackSubEvent and endTracking to track durations for // processes with multiple steps. For one-off events, use track method - startTracking(eventName: string) { + // Duplicate event properties will reflect latest value provided + startTracking(eventName: string, eventProperties = {}) { this.activeEvents.set( eventName, - new Map([['__startTrackingTime__', Date.now()]]) + new Map([ + ['__startTracking__', { timestamp: Date.now(), subEventProps: eventProperties }], + ]) ) } // See startTracking - trackSubEvent(eventName: string, subEventName: string) { + trackSubEvent(eventName: string, subEventName: string, eventProperties = {}) { if (!this.activeEvents.has(eventName)) { return this.Logger.warn(TAG, 'Attempted to track sub event for invalid event. Ignoring.') } - this.activeEvents.get(eventName)!.set(subEventName, Date.now()) + this.activeEvents + .get(eventName)! + .set(subEventName, { timestamp: Date.now(), subEventProps: eventProperties }) } // See startTracking - stopTracking(eventName: string, eventProperties = {}) { + stopTracking(eventName: string, eventProperties = {}, attachDeviceInfo = false) { if (!this.activeEvents.has(eventName)) { return } - const subEvents = this.activeEvents.get(eventName)! - if (subEvents.size === 1) { - return this.Logger.warn(TAG, 'stopTracking called for event without subEvents. Ignoring.') - } + this.activeEvents + .get(eventName)! + .set('__endTracking__', { timestamp: Date.now(), subEventProps: eventProperties }) + const subEvents = this.activeEvents.get(eventName)! const durations: { [subEventName: string]: number } = {} - let prevEventTime = subEvents.get('__startTrackingTime__')! - for (const [subEventName, timestamp] of subEvents) { - if (subEventName === '__startTrackingTime__') { + let prevEventTime = subEvents.get('__startTracking__')!.timestamp + let eventPropsSuperSet = subEvents.get('__startTracking__')!.subEventProps + + for (const [subEventName, { timestamp, subEventProps }] of subEvents) { + if (subEventName === '__startTracking__') { continue } + + eventPropsSuperSet = { ...eventPropsSuperSet, ...subEventProps } durations[subEventName] = timestamp - prevEventTime prevEventTime = timestamp } - durations.__totalTime__ = Date.now() - subEvents.get('__startTrackingTime__')! + durations.__totalTime__ = + subEvents.get('__endTracking__')!.timestamp - subEvents.get('__startTracking__')!.timestamp this.activeEvents.delete(eventName) - this.track(eventName, { ...eventProperties, ...durations }) + this.track(eventName, { ...eventPropsSuperSet, ...durations }, attachDeviceInfo) } - page(page: string, eventProperties: {}) { + page(page: string, eventProperties = {}) { if (!this.apiKey) { return } @@ -187,7 +202,7 @@ class CeloAnalytics { return whitelistedProps } - private getProps(eventProperties: {}): {} { + private getProps(eventProperties = {}): {} { const whitelistedProperties = this.applyWhitelist(eventProperties) const baseProps = { appName: this.appName,