Bluesky app fork with some witchin' additions 💫

Instrument signup (#8037)

authored by samuel.fm and committed by

GitHub 5ceaee57 7d1ebf6a

+182 -57
+16 -1
src/logger/metrics.ts
··· 51 51 } 52 52 'signup:captchaSuccess': {} 53 53 'signup:captchaFailure': {} 54 + 'signup:fieldError': { 55 + field: string 56 + errorCount: number 57 + errorMessage: string 58 + activeStep: number 59 + } 60 + 'signup:backgrounded': { 61 + activeStep: number 62 + backgroundCount: number 63 + } 64 + 'signup:handleTaken': {} 54 65 'signin:hostingProviderPressed': { 55 66 hostingProviderDidChange: boolean 56 67 } ··· 135 146 136 147 // Data events 137 148 'account:create:begin': {} 138 - 'account:create:success': {} 149 + 'account:create:success': { 150 + signupDuration: number 151 + fieldErrorsTotal: number 152 + backgroundCount: number 153 + } 139 154 'post:create': { 140 155 imageCount: number 141 156 isReply: boolean
+2 -3
src/screens/Signup/StepCaptcha/index.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 import {nanoid} from 'nanoid/non-secure' 6 6 7 - import {logEvent} from '#/lib/statsig/statsig' 8 7 import {createFullHandle} from '#/lib/strings/handles' 9 8 import {logger} from '#/logger' 10 9 import {ScreenTransition} from '#/screens/Login/ScreenTransition' ··· 40 39 const onSuccess = React.useCallback( 41 40 (code: string) => { 42 41 setCompleted(true) 43 - logEvent('signup:captchaSuccess', {}) 42 + logger.metric('signup:captchaSuccess', {}, {statsig: true}) 44 43 dispatch({ 45 44 type: 'submit', 46 45 task: {verificationCode: code, mutableProcessed: false}, ··· 55 54 type: 'setError', 56 55 value: _(msg`Error receiving captcha response.`), 57 56 }) 58 - logEvent('signup:captchaFailure', {}) 57 + logger.metric('signup:captchaFailure', {}, {statsig: true}) 59 58 logger.error('Signup Flow Error', { 60 59 registrationHandle: state.handle, 61 60 error,
+17 -9
src/screens/Signup/StepHandle.tsx
··· 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 6 import { 8 7 createFullHandle, 9 8 MAX_SERVICE_HANDLE_LENGTH, 10 9 validateServiceHandle, 11 10 } from '#/lib/strings/handles' 11 + import {logger} from '#/logger' 12 12 import {useAgent} from '#/state/session' 13 13 import {ScreenTransition} from '#/screens/Login/ScreenTransition' 14 14 import {useSignupContext} from '#/screens/Signup/state' ··· 53 53 dispatch({ 54 54 type: 'setError', 55 55 value: _(msg`That handle is already taken.`), 56 + field: 'handle', 56 57 }) 58 + logger.metric('signup:handleTaken', {}) 57 59 return 58 60 } 59 61 } catch (e) { ··· 62 64 dispatch({type: 'setIsLoading', value: false}) 63 65 } 64 66 65 - logEvent('signup:nextPressed', { 66 - activeStep: state.activeStep, 67 - phoneVerificationRequired: 68 - state.serviceDescription?.phoneVerificationRequired, 69 - }) 67 + logger.metric( 68 + 'signup:nextPressed', 69 + { 70 + activeStep: state.activeStep, 71 + phoneVerificationRequired: 72 + state.serviceDescription?.phoneVerificationRequired, 73 + }, 74 + {statsig: true}, 75 + ) 70 76 // phoneVerificationRequired is actually whether a captcha is required 71 77 if (!state.serviceDescription?.phoneVerificationRequired) { 72 78 dispatch({ ··· 92 98 value: handle, 93 99 }) 94 100 dispatch({type: 'prev'}) 95 - logEvent('signup:backPressed', { 96 - activeStep: state.activeStep, 97 - }) 101 + logger.metric( 102 + 'signup:backPressed', 103 + {activeStep: state.activeStep}, 104 + {statsig: true}, 105 + ) 98 106 }, [dispatch, state.activeStep]) 99 107 100 108 const validCheck = validateServiceHandle(draftValue, state.userDomain)
+9 -6
src/screens/Signup/StepInfo/index.tsx
··· 1 1 import React, {useRef} from 'react' 2 - import {TextInput, View} from 'react-native' 2 + import {type TextInput, View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import * as EmailValidator from 'email-validator' 6 6 import type tldts from 'tldts' 7 7 8 - import {logEvent} from '#/lib/statsig/statsig' 9 8 import {isEmailMaybeInvalid} from '#/lib/strings/email' 10 9 import {logger} from '#/logger' 11 10 import {ScreenTransition} from '#/screens/Login/ScreenTransition' ··· 13 12 import {Policies} from '#/screens/Signup/StepInfo/Policies' 14 13 import {atoms as a, native} from '#/alf' 15 14 import * as DateField from '#/components/forms/DateField' 16 - import {DateFieldRef} from '#/components/forms/DateField/types' 15 + import {type DateFieldRef} from '#/components/forms/DateField/types' 17 16 import {FormError} from '#/components/forms/FormError' 18 17 import {HostingProvider} from '#/components/forms/HostingProvider' 19 18 import * as TextField from '#/components/forms/TextField' ··· 134 133 dispatch({type: 'setEmail', value: email}) 135 134 dispatch({type: 'setPassword', value: password}) 136 135 dispatch({type: 'next'}) 137 - logEvent('signup:nextPressed', { 138 - activeStep: state.activeStep, 139 - }) 136 + logger.metric( 137 + 'signup:nextPressed', 138 + { 139 + activeStep: state.activeStep, 140 + }, 141 + {statsig: true}, 142 + ) 140 143 } 141 144 142 145 return (
+21 -7
src/screens/Signup/index.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 1 + import {useEffect, useReducer, useState} from 'react' 2 + import {AppState, type AppStateStatus, View} from 'react-native' 3 3 import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' 4 4 import {AppBskyGraphStarterpack} from '@atproto/api' 5 5 import {msg, Trans} from '@lingui/macro' ··· 31 31 export function Signup({onPressBack}: {onPressBack: () => void}) { 32 32 const {_} = useLingui() 33 33 const t = useTheme() 34 - const [state, dispatch] = React.useReducer(reducer, initialState) 34 + const [state, dispatch] = useReducer(reducer, initialState) 35 35 const {gtMobile} = useBreakpoints() 36 36 const submit = useSubmitSignup() 37 37 ··· 44 44 uri: activeStarterPack?.uri, 45 45 }) 46 46 47 - const [isFetchedAtMount] = React.useState(starterPack != null) 47 + const [isFetchedAtMount] = useState(starterPack != null) 48 48 const showStarterPackCard = 49 49 activeStarterPack?.uri && !isFetchingStarterPack && starterPack 50 50 ··· 55 55 refetch, 56 56 } = useServiceQuery(state.serviceUrl) 57 57 58 - React.useEffect(() => { 58 + useEffect(() => { 59 59 if (isFetching) { 60 60 dispatch({type: 'setIsLoading', value: true}) 61 61 } else if (!isFetching) { ··· 63 63 } 64 64 }, [isFetching]) 65 65 66 - React.useEffect(() => { 66 + useEffect(() => { 67 67 if (isError) { 68 68 dispatch({type: 'setServiceDescription', value: undefined}) 69 69 dispatch({ ··· 78 78 } 79 79 }, [_, serviceInfo, isError]) 80 80 81 - React.useEffect(() => { 81 + useEffect(() => { 82 82 if (state.pendingSubmit) { 83 83 if (!state.pendingSubmit.mutableProcessed) { 84 84 state.pendingSubmit.mutableProcessed = true ··· 86 86 } 87 87 } 88 88 }, [state, dispatch, submit]) 89 + 90 + // Track app backgrounding during signup 91 + useEffect(() => { 92 + const subscription = AppState.addEventListener( 93 + 'change', 94 + (nextAppState: AppStateStatus) => { 95 + if (nextAppState === 'background') { 96 + dispatch({type: 'incrementBackgroundCount'}) 97 + } 98 + }, 99 + ) 100 + 101 + return () => subscription.remove() 102 + }, []) 89 103 90 104 return ( 91 105 <SignupContext.Provider value={{state, dispatch}}>
+79 -11
src/screens/Signup/state.ts
··· 2 2 import {LayoutAnimation} from 'react-native' 3 3 import { 4 4 ComAtprotoServerCreateAccount, 5 - ComAtprotoServerDescribeServer, 5 + type ComAtprotoServerDescribeServer, 6 6 } from '@atproto/api' 7 7 import {msg} from '@lingui/macro' 8 8 import {useLingui} from '@lingui/react' ··· 56 56 isLoading: boolean 57 57 58 58 pendingSubmit: null | SubmitTask 59 + 60 + // Tracking 61 + signupStartTime: number 62 + fieldErrors: Record<ErrorField, number> 63 + backgroundCount: number 59 64 } 60 65 61 66 export type SignupAction = ··· 74 79 | {type: 'clearError'} 75 80 | {type: 'setIsLoading'; value: boolean} 76 81 | {type: 'submit'; task: SubmitTask} 82 + | {type: 'incrementBackgroundCount'} 77 83 78 84 export const initialState: SignupState = { 79 85 hasPrev: false, ··· 93 99 isLoading: false, 94 100 95 101 pendingSubmit: null, 102 + 103 + // Tracking 104 + signupStartTime: Date.now(), 105 + fieldErrors: { 106 + 'invite-code': 0, 107 + email: 0, 108 + handle: 0, 109 + password: 0, 110 + 'date-of-birth': 0, 111 + }, 112 + backgroundCount: 0, 96 113 } 97 114 98 115 export function is13(date: Date) { ··· 169 186 case 'setError': { 170 187 next.error = a.value 171 188 next.errorField = a.field 189 + 190 + // Track field errors 191 + if (a.field) { 192 + next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1 193 + 194 + // Log the field error 195 + logger.metric( 196 + 'signup:fieldError', 197 + { 198 + field: a.field, 199 + errorCount: next.fieldErrors[a.field], 200 + errorMessage: a.value, 201 + activeStep: next.activeStep, 202 + }, 203 + {statsig: true}, 204 + ) 205 + } 172 206 break 173 207 } 174 208 case 'clearError': { ··· 180 214 next.pendingSubmit = a.task 181 215 break 182 216 } 217 + case 'incrementBackgroundCount': { 218 + next.backgroundCount = s.backgroundCount + 1 219 + 220 + // Log background/foreground event during signup 221 + logger.metric( 222 + 'signup:backgrounded', 223 + { 224 + activeStep: next.activeStep, 225 + backgroundCount: next.backgroundCount, 226 + }, 227 + {statsig: true}, 228 + ) 229 + break 230 + } 183 231 } 184 232 185 233 next.hasPrev = next.activeStep !== SignupStep.INFO ··· 212 260 return dispatch({ 213 261 type: 'setError', 214 262 value: _(msg`Please enter your email.`), 263 + field: 'email', 215 264 }) 216 265 } 217 266 if (!EmailValidator.validate(state.email)) { ··· 219 268 return dispatch({ 220 269 type: 'setError', 221 270 value: _(msg`Your email appears to be invalid.`), 271 + field: 'email', 222 272 }) 223 273 } 224 274 if (!state.password) { ··· 226 276 return dispatch({ 227 277 type: 'setError', 228 278 value: _(msg`Please choose your password.`), 279 + field: 'password', 229 280 }) 230 281 } 231 282 if (!state.handle) { ··· 233 284 return dispatch({ 234 285 type: 'setError', 235 286 value: _(msg`Please choose your handle.`), 287 + field: 'handle', 236 288 }) 237 289 } 238 290 if ( ··· 253 305 dispatch({type: 'setIsLoading', value: true}) 254 306 255 307 try { 256 - await createAccount({ 257 - service: state.serviceUrl, 258 - email: state.email, 259 - handle: createFullHandle(state.handle, state.userDomain), 260 - password: state.password, 261 - birthDate: state.dateOfBirth, 262 - inviteCode: state.inviteCode.trim(), 263 - verificationCode: state.pendingSubmit?.verificationCode, 264 - }) 308 + await createAccount( 309 + { 310 + service: state.serviceUrl, 311 + email: state.email, 312 + handle: createFullHandle(state.handle, state.userDomain), 313 + password: state.password, 314 + birthDate: state.dateOfBirth, 315 + inviteCode: state.inviteCode.trim(), 316 + verificationCode: state.pendingSubmit?.verificationCode, 317 + }, 318 + { 319 + signupDuration: Date.now() - state.signupStartTime, 320 + fieldErrorsTotal: Object.values(state.fieldErrors).reduce( 321 + (a, b) => a + b, 322 + 0, 323 + ), 324 + backgroundCount: state.backgroundCount, 325 + }, 326 + ) 327 + 265 328 /* 266 329 * Must happen last so that if the user has multiple tabs open and 267 330 * createAccount fails, one tab is not stuck in onboarding — Eric ··· 275 338 value: _( 276 339 msg`Invite code not accepted. Check that you input it correctly and try again.`, 277 340 ), 341 + field: 'invite-code', 278 342 }) 279 343 dispatch({type: 'setStep', value: SignupStep.INFO}) 280 344 return ··· 284 348 const isHandleError = error.toLowerCase().includes('handle') 285 349 286 350 dispatch({type: 'setIsLoading', value: false}) 287 - dispatch({type: 'setError', value: error}) 351 + dispatch({ 352 + type: 'setError', 353 + value: error, 354 + field: isHandleError ? 'handle' : undefined, 355 + }) 288 356 dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 289 357 290 358 logger.error('Signup Flow Error', {
+25 -10
src/state/session/index.tsx
··· 1 1 import React from 'react' 2 - import {AtpSessionEvent, BskyAgent} from '@atproto/api' 2 + import {type AtpSessionEvent, type BskyAgent} from '@atproto/api' 3 3 4 - import {logEvent} from '#/lib/statsig/statsig' 5 4 import {isWeb} from '#/platform/detection' 6 5 import * as persisted from '#/state/persisted' 7 6 import {useCloseAllActiveElements} from '#/state/util' ··· 9 8 import {emitSessionDropped} from '../events' 10 9 import { 11 10 agentToSessionAccount, 12 - BskyAppAgent, 11 + type BskyAppAgent, 13 12 createAgentAndCreateAccount, 14 13 createAgentAndLogin, 15 14 createAgentAndResume, ··· 20 19 export {isSignupQueued} from './util' 21 20 import {addSessionDebugLog} from './logging' 22 21 export type {SessionAccount} from '#/state/session/types' 23 - import {SessionApiContext, SessionStateContext} from '#/state/session/types' 22 + import {logger} from '#/logger' 23 + import { 24 + type SessionApiContext, 25 + type SessionStateContext, 26 + } from '#/state/session/types' 24 27 25 28 const StateContext = React.createContext<SessionStateContext>({ 26 29 accounts: [], ··· 65 68 ) 66 69 67 70 const createAccount = React.useCallback<SessionApiContext['createAccount']>( 68 - async params => { 71 + async (params, metrics) => { 69 72 addSessionDebugLog({type: 'method:start', method: 'createAccount'}) 70 73 const signal = cancelPendingTask() 71 - logEvent('account:create:begin', {}) 74 + logger.metric('account:create:begin', {}, {statsig: true}) 72 75 const {agent, account} = await createAgentAndCreateAccount( 73 76 params, 74 77 onAgentSessionChange, ··· 82 85 newAgent: agent, 83 86 newAccount: account, 84 87 }) 85 - logEvent('account:create:success', {}) 88 + logger.metric('account:create:success', metrics, {statsig: true}) 86 89 addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) 87 90 }, 88 91 [onAgentSessionChange, cancelPendingTask], ··· 105 108 newAgent: agent, 106 109 newAccount: account, 107 110 }) 108 - logEvent('account:loggedIn', {logContext, withPassword: true}) 111 + logger.metric( 112 + 'account:loggedIn', 113 + {logContext, withPassword: true}, 114 + {statsig: true}, 115 + ) 109 116 addSessionDebugLog({type: 'method:end', method: 'login', account}) 110 117 }, 111 118 [onAgentSessionChange, cancelPendingTask], ··· 120 127 dispatch({ 121 128 type: 'logged-out-current-account', 122 129 }) 123 - logEvent('account:loggedOut', {logContext, scope: 'current'}) 130 + logger.metric( 131 + 'account:loggedOut', 132 + {logContext, scope: 'current'}, 133 + {statsig: true}, 134 + ) 124 135 addSessionDebugLog({type: 'method:end', method: 'logout'}) 125 136 }, 126 137 [cancelPendingTask], ··· 135 146 dispatch({ 136 147 type: 'logged-out-every-account', 137 148 }) 138 - logEvent('account:loggedOut', {logContext, scope: 'every'}) 149 + logger.metric( 150 + 'account:loggedOut', 151 + {logContext, scope: 'every'}, 152 + {statsig: true}, 153 + ) 139 154 addSessionDebugLog({type: 'method:end', method: 'logout'}) 140 155 }, 141 156 [cancelPendingTask],
+13 -10
src/state/session/types.ts
··· 10 10 } 11 11 12 12 export type SessionApiContext = { 13 - createAccount: (props: { 14 - service: string 15 - email: string 16 - password: string 17 - handle: string 18 - birthDate: Date 19 - inviteCode?: string 20 - verificationPhone?: string 21 - verificationCode?: string 22 - }) => Promise<void> 13 + createAccount: ( 14 + props: { 15 + service: string 16 + email: string 17 + password: string 18 + handle: string 19 + birthDate: Date 20 + inviteCode?: string 21 + verificationPhone?: string 22 + verificationCode?: string 23 + }, 24 + metrics: LogEvents['account:create:success'], 25 + ) => Promise<void> 23 26 login: ( 24 27 props: { 25 28 service: string