Instrument signup (#8037)

This commit is contained in:
Samuel Newman
2025-03-27 20:17:07 +02:00
committed by GitHub
parent 7d1ebf6a02
commit 5ceaee5793
8 changed files with 182 additions and 57 deletions
+16 -1
View File
@@ -51,6 +51,17 @@ export type MetricEvents = {
} }
'signup:captchaSuccess': {} 'signup:captchaSuccess': {}
'signup:captchaFailure': {} 'signup:captchaFailure': {}
'signup:fieldError': {
field: string
errorCount: number
errorMessage: string
activeStep: number
}
'signup:backgrounded': {
activeStep: number
backgroundCount: number
}
'signup:handleTaken': {}
'signin:hostingProviderPressed': { 'signin:hostingProviderPressed': {
hostingProviderDidChange: boolean hostingProviderDidChange: boolean
} }
@@ -135,7 +146,11 @@ export type MetricEvents = {
// Data events // Data events
'account:create:begin': {} 'account:create:begin': {}
'account:create:success': {} 'account:create:success': {
signupDuration: number
fieldErrorsTotal: number
backgroundCount: number
}
'post:create': { 'post:create': {
imageCount: number imageCount: number
isReply: boolean isReply: boolean
+2 -3
View File
@@ -4,7 +4,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {ScreenTransition} from '#/screens/Login/ScreenTransition'
@@ -40,7 +39,7 @@ export function StepCaptcha() {
const onSuccess = React.useCallback( const onSuccess = React.useCallback(
(code: string) => { (code: string) => {
setCompleted(true) setCompleted(true)
logEvent('signup:captchaSuccess', {}) logger.metric('signup:captchaSuccess', {}, {statsig: true})
dispatch({ dispatch({
type: 'submit', type: 'submit',
task: {verificationCode: code, mutableProcessed: false}, task: {verificationCode: code, mutableProcessed: false},
@@ -55,7 +54,7 @@ export function StepCaptcha() {
type: 'setError', type: 'setError',
value: _(msg`Error receiving captcha response.`), value: _(msg`Error receiving captcha response.`),
}) })
logEvent('signup:captchaFailure', {}) logger.metric('signup:captchaFailure', {}, {statsig: true})
logger.error('Signup Flow Error', { logger.error('Signup Flow Error', {
registrationHandle: state.handle, registrationHandle: state.handle,
error, error,
+14 -6
View File
@@ -3,12 +3,12 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import { import {
createFullHandle, createFullHandle,
MAX_SERVICE_HANDLE_LENGTH, MAX_SERVICE_HANDLE_LENGTH,
validateServiceHandle, validateServiceHandle,
} from '#/lib/strings/handles' } from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext} from '#/screens/Signup/state' import {useSignupContext} from '#/screens/Signup/state'
@@ -53,7 +53,9 @@ export function StepHandle() {
dispatch({ dispatch({
type: 'setError', type: 'setError',
value: _(msg`That handle is already taken.`), value: _(msg`That handle is already taken.`),
field: 'handle',
}) })
logger.metric('signup:handleTaken', {})
return return
} }
} catch (e) { } catch (e) {
@@ -62,11 +64,15 @@ export function StepHandle() {
dispatch({type: 'setIsLoading', value: false}) dispatch({type: 'setIsLoading', value: false})
} }
logEvent('signup:nextPressed', { logger.metric(
'signup:nextPressed',
{
activeStep: state.activeStep, activeStep: state.activeStep,
phoneVerificationRequired: phoneVerificationRequired:
state.serviceDescription?.phoneVerificationRequired, state.serviceDescription?.phoneVerificationRequired,
}) },
{statsig: true},
)
// phoneVerificationRequired is actually whether a captcha is required // phoneVerificationRequired is actually whether a captcha is required
if (!state.serviceDescription?.phoneVerificationRequired) { if (!state.serviceDescription?.phoneVerificationRequired) {
dispatch({ dispatch({
@@ -92,9 +98,11 @@ export function StepHandle() {
value: handle, value: handle,
}) })
dispatch({type: 'prev'}) dispatch({type: 'prev'})
logEvent('signup:backPressed', { logger.metric(
activeStep: state.activeStep, 'signup:backPressed',
}) {activeStep: state.activeStep},
{statsig: true},
)
}, [dispatch, state.activeStep]) }, [dispatch, state.activeStep])
const validCheck = validateServiceHandle(draftValue, state.userDomain) const validCheck = validateServiceHandle(draftValue, state.userDomain)
+8 -5
View File
@@ -1,11 +1,10 @@
import React, {useRef} from 'react' import React, {useRef} from 'react'
import {TextInput, View} from 'react-native' import {type TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import type tldts from 'tldts' import type tldts from 'tldts'
import {logEvent} from '#/lib/statsig/statsig'
import {isEmailMaybeInvalid} from '#/lib/strings/email' import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {ScreenTransition} from '#/screens/Login/ScreenTransition'
@@ -13,7 +12,7 @@ import {is13, is18, useSignupContext} from '#/screens/Signup/state'
import {Policies} from '#/screens/Signup/StepInfo/Policies' import {Policies} from '#/screens/Signup/StepInfo/Policies'
import {atoms as a, native} from '#/alf' import {atoms as a, native} from '#/alf'
import * as DateField from '#/components/forms/DateField' import * as DateField from '#/components/forms/DateField'
import {DateFieldRef} from '#/components/forms/DateField/types' import {type DateFieldRef} from '#/components/forms/DateField/types'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider' import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
@@ -134,9 +133,13 @@ export function StepInfo({
dispatch({type: 'setEmail', value: email}) dispatch({type: 'setEmail', value: email})
dispatch({type: 'setPassword', value: password}) dispatch({type: 'setPassword', value: password})
dispatch({type: 'next'}) dispatch({type: 'next'})
logEvent('signup:nextPressed', { logger.metric(
'signup:nextPressed',
{
activeStep: state.activeStep, activeStep: state.activeStep,
}) },
{statsig: true},
)
} }
return ( return (
+21 -7
View File
@@ -1,5 +1,5 @@
import React from 'react' import {useEffect, useReducer, useState} from 'react'
import {View} from 'react-native' import {AppState, type AppStateStatus, View} from 'react-native'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api' import {AppBskyGraphStarterpack} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -31,7 +31,7 @@ import * as bsky from '#/types/bsky'
export function Signup({onPressBack}: {onPressBack: () => void}) { export function Signup({onPressBack}: {onPressBack: () => void}) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const [state, dispatch] = React.useReducer(reducer, initialState) const [state, dispatch] = useReducer(reducer, initialState)
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const submit = useSubmitSignup() const submit = useSubmitSignup()
@@ -44,7 +44,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
uri: activeStarterPack?.uri, uri: activeStarterPack?.uri,
}) })
const [isFetchedAtMount] = React.useState(starterPack != null) const [isFetchedAtMount] = useState(starterPack != null)
const showStarterPackCard = const showStarterPackCard =
activeStarterPack?.uri && !isFetchingStarterPack && starterPack activeStarterPack?.uri && !isFetchingStarterPack && starterPack
@@ -55,7 +55,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
refetch, refetch,
} = useServiceQuery(state.serviceUrl) } = useServiceQuery(state.serviceUrl)
React.useEffect(() => { useEffect(() => {
if (isFetching) { if (isFetching) {
dispatch({type: 'setIsLoading', value: true}) dispatch({type: 'setIsLoading', value: true})
} else if (!isFetching) { } else if (!isFetching) {
@@ -63,7 +63,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
} }
}, [isFetching]) }, [isFetching])
React.useEffect(() => { useEffect(() => {
if (isError) { if (isError) {
dispatch({type: 'setServiceDescription', value: undefined}) dispatch({type: 'setServiceDescription', value: undefined})
dispatch({ dispatch({
@@ -78,7 +78,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
} }
}, [_, serviceInfo, isError]) }, [_, serviceInfo, isError])
React.useEffect(() => { useEffect(() => {
if (state.pendingSubmit) { if (state.pendingSubmit) {
if (!state.pendingSubmit.mutableProcessed) { if (!state.pendingSubmit.mutableProcessed) {
state.pendingSubmit.mutableProcessed = true state.pendingSubmit.mutableProcessed = true
@@ -87,6 +87,20 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
} }
}, [state, dispatch, submit]) }, [state, dispatch, submit])
// Track app backgrounding during signup
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(nextAppState: AppStateStatus) => {
if (nextAppState === 'background') {
dispatch({type: 'incrementBackgroundCount'})
}
},
)
return () => subscription.remove()
}, [])
return ( return (
<SignupContext.Provider value={{state, dispatch}}> <SignupContext.Provider value={{state, dispatch}}>
<LoggedOutLayout <LoggedOutLayout
+72 -4
View File
@@ -2,7 +2,7 @@ import React, {useCallback} from 'react'
import {LayoutAnimation} from 'react-native' import {LayoutAnimation} from 'react-native'
import { import {
ComAtprotoServerCreateAccount, ComAtprotoServerCreateAccount,
ComAtprotoServerDescribeServer, type ComAtprotoServerDescribeServer,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -56,6 +56,11 @@ export type SignupState = {
isLoading: boolean isLoading: boolean
pendingSubmit: null | SubmitTask pendingSubmit: null | SubmitTask
// Tracking
signupStartTime: number
fieldErrors: Record<ErrorField, number>
backgroundCount: number
} }
export type SignupAction = export type SignupAction =
@@ -74,6 +79,7 @@ export type SignupAction =
| {type: 'clearError'} | {type: 'clearError'}
| {type: 'setIsLoading'; value: boolean} | {type: 'setIsLoading'; value: boolean}
| {type: 'submit'; task: SubmitTask} | {type: 'submit'; task: SubmitTask}
| {type: 'incrementBackgroundCount'}
export const initialState: SignupState = { export const initialState: SignupState = {
hasPrev: false, hasPrev: false,
@@ -93,6 +99,17 @@ export const initialState: SignupState = {
isLoading: false, isLoading: false,
pendingSubmit: null, pendingSubmit: null,
// Tracking
signupStartTime: Date.now(),
fieldErrors: {
'invite-code': 0,
email: 0,
handle: 0,
password: 0,
'date-of-birth': 0,
},
backgroundCount: 0,
} }
export function is13(date: Date) { export function is13(date: Date) {
@@ -169,6 +186,23 @@ export function reducer(s: SignupState, a: SignupAction): SignupState {
case 'setError': { case 'setError': {
next.error = a.value next.error = a.value
next.errorField = a.field next.errorField = a.field
// Track field errors
if (a.field) {
next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1
// Log the field error
logger.metric(
'signup:fieldError',
{
field: a.field,
errorCount: next.fieldErrors[a.field],
errorMessage: a.value,
activeStep: next.activeStep,
},
{statsig: true},
)
}
break break
} }
case 'clearError': { case 'clearError': {
@@ -180,6 +214,20 @@ export function reducer(s: SignupState, a: SignupAction): SignupState {
next.pendingSubmit = a.task next.pendingSubmit = a.task
break break
} }
case 'incrementBackgroundCount': {
next.backgroundCount = s.backgroundCount + 1
// Log background/foreground event during signup
logger.metric(
'signup:backgrounded',
{
activeStep: next.activeStep,
backgroundCount: next.backgroundCount,
},
{statsig: true},
)
break
}
} }
next.hasPrev = next.activeStep !== SignupStep.INFO next.hasPrev = next.activeStep !== SignupStep.INFO
@@ -212,6 +260,7 @@ export function useSubmitSignup() {
return dispatch({ return dispatch({
type: 'setError', type: 'setError',
value: _(msg`Please enter your email.`), value: _(msg`Please enter your email.`),
field: 'email',
}) })
} }
if (!EmailValidator.validate(state.email)) { if (!EmailValidator.validate(state.email)) {
@@ -219,6 +268,7 @@ export function useSubmitSignup() {
return dispatch({ return dispatch({
type: 'setError', type: 'setError',
value: _(msg`Your email appears to be invalid.`), value: _(msg`Your email appears to be invalid.`),
field: 'email',
}) })
} }
if (!state.password) { if (!state.password) {
@@ -226,6 +276,7 @@ export function useSubmitSignup() {
return dispatch({ return dispatch({
type: 'setError', type: 'setError',
value: _(msg`Please choose your password.`), value: _(msg`Please choose your password.`),
field: 'password',
}) })
} }
if (!state.handle) { if (!state.handle) {
@@ -233,6 +284,7 @@ export function useSubmitSignup() {
return dispatch({ return dispatch({
type: 'setError', type: 'setError',
value: _(msg`Please choose your handle.`), value: _(msg`Please choose your handle.`),
field: 'handle',
}) })
} }
if ( if (
@@ -253,7 +305,8 @@ export function useSubmitSignup() {
dispatch({type: 'setIsLoading', value: true}) dispatch({type: 'setIsLoading', value: true})
try { try {
await createAccount({ await createAccount(
{
service: state.serviceUrl, service: state.serviceUrl,
email: state.email, email: state.email,
handle: createFullHandle(state.handle, state.userDomain), handle: createFullHandle(state.handle, state.userDomain),
@@ -261,7 +314,17 @@ export function useSubmitSignup() {
birthDate: state.dateOfBirth, birthDate: state.dateOfBirth,
inviteCode: state.inviteCode.trim(), inviteCode: state.inviteCode.trim(),
verificationCode: state.pendingSubmit?.verificationCode, verificationCode: state.pendingSubmit?.verificationCode,
}) },
{
signupDuration: Date.now() - state.signupStartTime,
fieldErrorsTotal: Object.values(state.fieldErrors).reduce(
(a, b) => a + b,
0,
),
backgroundCount: state.backgroundCount,
},
)
/* /*
* Must happen last so that if the user has multiple tabs open and * Must happen last so that if the user has multiple tabs open and
* createAccount fails, one tab is not stuck in onboarding — Eric * createAccount fails, one tab is not stuck in onboarding — Eric
@@ -275,6 +338,7 @@ export function useSubmitSignup() {
value: _( value: _(
msg`Invite code not accepted. Check that you input it correctly and try again.`, msg`Invite code not accepted. Check that you input it correctly and try again.`,
), ),
field: 'invite-code',
}) })
dispatch({type: 'setStep', value: SignupStep.INFO}) dispatch({type: 'setStep', value: SignupStep.INFO})
return return
@@ -284,7 +348,11 @@ export function useSubmitSignup() {
const isHandleError = error.toLowerCase().includes('handle') const isHandleError = error.toLowerCase().includes('handle')
dispatch({type: 'setIsLoading', value: false}) dispatch({type: 'setIsLoading', value: false})
dispatch({type: 'setError', value: error}) dispatch({
type: 'setError',
value: error,
field: isHandleError ? 'handle' : undefined,
})
dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
logger.error('Signup Flow Error', { logger.error('Signup Flow Error', {
+25 -10
View File
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {AtpSessionEvent, BskyAgent} from '@atproto/api' import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
@@ -9,7 +8,7 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {emitSessionDropped} from '../events' import {emitSessionDropped} from '../events'
import { import {
agentToSessionAccount, agentToSessionAccount,
BskyAppAgent, type BskyAppAgent,
createAgentAndCreateAccount, createAgentAndCreateAccount,
createAgentAndLogin, createAgentAndLogin,
createAgentAndResume, createAgentAndResume,
@@ -20,7 +19,11 @@ import {getInitialState, reducer} from './reducer'
export {isSignupQueued} from './util' export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging' import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types' export type {SessionAccount} from '#/state/session/types'
import {SessionApiContext, SessionStateContext} from '#/state/session/types' import {logger} from '#/logger'
import {
type SessionApiContext,
type SessionStateContext,
} from '#/state/session/types'
const StateContext = React.createContext<SessionStateContext>({ const StateContext = React.createContext<SessionStateContext>({
accounts: [], accounts: [],
@@ -65,10 +68,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const createAccount = React.useCallback<SessionApiContext['createAccount']>( const createAccount = React.useCallback<SessionApiContext['createAccount']>(
async params => { async (params, metrics) => {
addSessionDebugLog({type: 'method:start', method: 'createAccount'}) addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask() const signal = cancelPendingTask()
logEvent('account:create:begin', {}) logger.metric('account:create:begin', {}, {statsig: true})
const {agent, account} = await createAgentAndCreateAccount( const {agent, account} = await createAgentAndCreateAccount(
params, params,
onAgentSessionChange, onAgentSessionChange,
@@ -82,7 +85,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: agent, newAgent: agent,
newAccount: account, newAccount: account,
}) })
logEvent('account:create:success', {}) logger.metric('account:create:success', metrics, {statsig: true})
addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
}, },
[onAgentSessionChange, cancelPendingTask], [onAgentSessionChange, cancelPendingTask],
@@ -105,7 +108,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: agent, newAgent: agent,
newAccount: account, newAccount: account,
}) })
logEvent('account:loggedIn', {logContext, withPassword: true}) logger.metric(
'account:loggedIn',
{logContext, withPassword: true},
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'login', account}) addSessionDebugLog({type: 'method:end', method: 'login', account})
}, },
[onAgentSessionChange, cancelPendingTask], [onAgentSessionChange, cancelPendingTask],
@@ -120,7 +127,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
dispatch({ dispatch({
type: 'logged-out-current-account', type: 'logged-out-current-account',
}) })
logEvent('account:loggedOut', {logContext, scope: 'current'}) logger.metric(
'account:loggedOut',
{logContext, scope: 'current'},
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'}) addSessionDebugLog({type: 'method:end', method: 'logout'})
}, },
[cancelPendingTask], [cancelPendingTask],
@@ -135,7 +146,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
dispatch({ dispatch({
type: 'logged-out-every-account', type: 'logged-out-every-account',
}) })
logEvent('account:loggedOut', {logContext, scope: 'every'}) logger.metric(
'account:loggedOut',
{logContext, scope: 'every'},
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'}) addSessionDebugLog({type: 'method:end', method: 'logout'})
}, },
[cancelPendingTask], [cancelPendingTask],
+5 -2
View File
@@ -10,7 +10,8 @@ export type SessionStateContext = {
} }
export type SessionApiContext = { export type SessionApiContext = {
createAccount: (props: { createAccount: (
props: {
service: string service: string
email: string email: string
password: string password: string
@@ -19,7 +20,9 @@ export type SessionApiContext = {
inviteCode?: string inviteCode?: string
verificationPhone?: string verificationPhone?: string
verificationCode?: string verificationCode?: string
}) => Promise<void> },
metrics: LogEvents['account:create:success'],
) => Promise<void>
login: ( login: (
props: { props: {
service: string service: string