Bluesky app fork with some witchin' additions 💫

[APP-1782] Analytics migration (#9734)

* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests

authored by

Eric Bailey and committed by
GitHub
25a1ed12 8614e5e1

+2358 -1689
+7
.env.example
··· 28 28 # 29 29 # 30 30 31 + # Bluesky's metrics API 32 + EXPO_PUBLIC_METRICS_API_HOST= 33 + 34 + # Growthbook config 35 + EXPO_PUBLIC_GROWTHBOOK_API_HOST= 36 + EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= 37 + 31 38 # Sentry DSN for telemetry 32 39 EXPO_PUBLIC_SENTRY_DSN= 33 40
-1
eslint.config.mjs
··· 119 119 }, 120 120 ], 121 121 'bsky-internal/use-exact-imports': 'error', 122 - 'bsky-internal/use-typed-gates': 'error', 123 122 'bsky-internal/use-prefixed-imports': 'error', 124 123 125 124 /**
-1
eslint/index.js
··· 8 8 rules: { 9 9 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 10 10 'use-exact-imports': require('./use-exact-imports'), 11 - 'use-typed-gates': require('./use-typed-gates'), 12 11 'use-prefixed-imports': require('./use-prefixed-imports'), 13 12 }, 14 13 }
-41
eslint/use-typed-gates.js
··· 1 - 'use strict' 2 - 3 - module.exports = { 4 - meta: { 5 - type: 'suggestion', 6 - docs: { 7 - description: 8 - 'Enforce using internal statsig wrapper instead of npm package', 9 - }, 10 - schema: [], 11 - }, 12 - create(context) { 13 - return { 14 - ImportSpecifier(node) { 15 - if ( 16 - !node.local || 17 - node.local.type !== 'Identifier' || 18 - node.local.name !== 'useGate' 19 - ) { 20 - return 21 - } 22 - if ( 23 - node.parent.type !== 'ImportDeclaration' || 24 - !node.parent.source || 25 - node.parent.source.type !== 'Literal' 26 - ) { 27 - return 28 - } 29 - const source = node.parent.source.value 30 - if (source.startsWith('statsig') || source.startsWith('@statsig')) { 31 - context.report({ 32 - node, 33 - message: 34 - "Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.", 35 - }) 36 - } 37 - // TODO: Verify gate() call results aren't stored in variables. 38 - }, 39 - } 40 - }, 41 - }
+1 -11
jest/jestSetup.js
··· 99 99 requireNativeViewManager: jest.fn().mockImplementation(_ => { 100 100 return () => null 101 101 }), 102 + createPermissionHook: () => () => [true], 102 103 })) 103 104 104 105 jest.mock('expo-localization', () => ({ 105 106 getLocales: () => [], 106 107 })) 107 - 108 - jest.mock('statsig-react-native-expo', () => ({ 109 - Statsig: { 110 - initialize() {}, 111 - initializeCalled() { 112 - return false 113 - }, 114 - }, 115 - })) 116 - 117 - jest.mock('../src/lib/statsig/statsig', () => ({}))
+1 -1
package.json
··· 93 93 "@fortawesome/free-regular-svg-icons": "^6.1.1", 94 94 "@fortawesome/free-solid-svg-icons": "^6.1.1", 95 95 "@fortawesome/react-native-fontawesome": "^0.3.2", 96 + "@growthbook/growthbook-react": "^1.6.2", 96 97 "@haileyok/bluesky-video": "0.3.2", 97 98 "@ipld/dag-cbor": "^9.2.0", 98 99 "@lingui/react": "^4.14.1", ··· 219 220 "react-textarea-autosize": "^8.5.3", 220 221 "sonner": "^2.0.7", 221 222 "sonner-native": "^0.21.0", 222 - "statsig-react-native-expo": "^4.6.1", 223 223 "tippy.js": "^6.3.7", 224 224 "tlds": "^1.234.0", 225 225 "tldts": "^6.1.46",
+40 -33
src/App.native.tsx
··· 17 17 import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' 18 18 import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' 19 19 import {QueryProvider} from '#/lib/react-query' 20 - import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' 21 20 import {s} from '#/lib/styles' 22 21 import {ThemeProvider} from '#/lib/ThemeContext' 23 22 import I18nProvider from '#/locale/i18nProvider' ··· 69 68 prefetchAgeAssuranceConfig, 70 69 Provider as AgeAssuranceV2Provider, 71 70 } from '#/ageAssurance' 71 + import { 72 + AnalyticsContext, 73 + AnalyticsFeaturesContext, 74 + features, 75 + setupDeviceId, 76 + } from '#/analytics' 72 77 import {IS_ANDROID, IS_IOS} from '#/env' 73 78 import { 74 79 prefetchLiveEvents, ··· 114 119 if (account) { 115 120 await resumeSession(account) 116 121 } else { 117 - await tryFetchGates(undefined, 'prefer-fresh-gates') 122 + await features.init 118 123 } 119 124 } catch (e) { 120 125 logger.error(`session: resume failed`, {message: e}) ··· 144 149 <React.Fragment 145 150 // Resets the entire tree below when it changes: 146 151 key={currentAccount?.did}> 147 - <QueryProvider currentDid={currentAccount?.did}> 148 - <PolicyUpdateOverlayProvider> 149 - <StatsigProvider> 152 + <AnalyticsFeaturesContext> 153 + <QueryProvider currentDid={currentAccount?.did}> 154 + <PolicyUpdateOverlayProvider> 150 155 <LiveEventsProvider> 151 156 <AgeAssuranceV2Provider> 152 157 <ComposerProvider> ··· 192 197 </ComposerProvider> 193 198 </AgeAssuranceV2Provider> 194 199 </LiveEventsProvider> 195 - </StatsigProvider> 196 - </PolicyUpdateOverlayProvider> 197 - </QueryProvider> 200 + </PolicyUpdateOverlayProvider> 201 + </QueryProvider> 202 + </AnalyticsFeaturesContext> 198 203 </React.Fragment> 199 204 </VideoVolumeProvider> 200 205 </Splash> ··· 208 213 const [isReady, setReady] = useState(false) 209 214 210 215 React.useEffect(() => { 211 - Promise.all([initPersistedState(), Geo.resolve()]).then(() => 216 + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => 212 217 setReady(true), 213 218 ) 214 219 }, []) ··· 226 231 <A11yProvider> 227 232 <KeyboardControllerProvider> 228 233 <OnboardingProvider> 229 - <SessionProvider> 230 - <PrefsStateProvider> 231 - <I18nProvider> 232 - <ShellStateProvider> 233 - <ModalStateProvider> 234 - <DialogStateProvider> 235 - <LightboxStateProvider> 236 - <PortalProvider> 237 - <BottomSheetProvider> 238 - <StarterPackProvider> 239 - <SafeAreaProvider 240 - initialMetrics={initialWindowMetrics}> 241 - <InnerApp /> 242 - </SafeAreaProvider> 243 - </StarterPackProvider> 244 - </BottomSheetProvider> 245 - </PortalProvider> 246 - </LightboxStateProvider> 247 - </DialogStateProvider> 248 - </ModalStateProvider> 249 - </ShellStateProvider> 250 - </I18nProvider> 251 - </PrefsStateProvider> 252 - </SessionProvider> 234 + <AnalyticsContext> 235 + <SessionProvider> 236 + <PrefsStateProvider> 237 + <I18nProvider> 238 + <ShellStateProvider> 239 + <ModalStateProvider> 240 + <DialogStateProvider> 241 + <LightboxStateProvider> 242 + <PortalProvider> 243 + <BottomSheetProvider> 244 + <StarterPackProvider> 245 + <SafeAreaProvider 246 + initialMetrics={initialWindowMetrics}> 247 + <InnerApp /> 248 + </SafeAreaProvider> 249 + </StarterPackProvider> 250 + </BottomSheetProvider> 251 + </PortalProvider> 252 + </LightboxStateProvider> 253 + </DialogStateProvider> 254 + </ModalStateProvider> 255 + </ShellStateProvider> 256 + </I18nProvider> 257 + </PrefsStateProvider> 258 + </SessionProvider> 259 + </AnalyticsContext> 253 260 </OnboardingProvider> 254 261 </KeyboardControllerProvider> 255 262 </A11yProvider>
+40 -29
src/App.web.tsx
··· 9 9 import * as Sentry from '@sentry/react-native' 10 10 11 11 import {QueryProvider} from '#/lib/react-query' 12 - import {Provider as StatsigProvider} from '#/lib/statsig/statsig' 13 12 import {ThemeProvider} from '#/lib/ThemeContext' 14 13 import I18nProvider from '#/locale/i18nProvider' 15 14 import {logger} from '#/logger' ··· 55 54 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 56 55 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 57 56 import {ToastOutlet} from '#/components/Toast' 58 - import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 59 - import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 57 + import { 58 + prefetchAgeAssuranceConfig, 59 + Provider as AgeAssuranceV2Provider, 60 + } from '#/ageAssurance' 61 + import { 62 + AnalyticsContext, 63 + AnalyticsFeaturesContext, 64 + features, 65 + setupDeviceId, 66 + } from '#/analytics' 60 67 import { 61 68 prefetchLiveEvents, 62 69 Provider as LiveEventsProvider, ··· 87 94 try { 88 95 if (account) { 89 96 await resumeSession(account) 97 + } else { 98 + await features.init 90 99 } 91 100 } catch (e) { 92 101 logger.error(`session: resumeSession failed`, {message: e}) ··· 119 128 <React.Fragment 120 129 // Resets the entire tree below when it changes: 121 130 key={currentAccount?.did}> 122 - <QueryProvider currentDid={currentAccount?.did}> 123 - <PolicyUpdateOverlayProvider> 124 - <StatsigProvider> 131 + <AnalyticsFeaturesContext> 132 + <QueryProvider currentDid={currentAccount?.did}> 133 + <PolicyUpdateOverlayProvider> 125 134 <LiveEventsProvider> 126 135 <AgeAssuranceV2Provider> 127 136 <ComposerProvider> ··· 163 172 </ComposerProvider> 164 173 </AgeAssuranceV2Provider> 165 174 </LiveEventsProvider> 166 - </StatsigProvider> 167 - </PolicyUpdateOverlayProvider> 168 - </QueryProvider> 175 + </PolicyUpdateOverlayProvider> 176 + </QueryProvider> 177 + </AnalyticsFeaturesContext> 169 178 </React.Fragment> 170 179 </ActiveVideoProvider> 171 180 </VideoVolumeProvider> ··· 179 188 const [isReady, setReady] = useState(false) 180 189 181 190 React.useEffect(() => { 182 - Promise.all([initPersistedState(), Geo.resolve()]).then(() => 191 + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => 183 192 setReady(true), 184 193 ) 185 194 }, []) ··· 196 205 <Geo.Provider> 197 206 <A11yProvider> 198 207 <OnboardingProvider> 199 - <SessionProvider> 200 - <PrefsStateProvider> 201 - <I18nProvider> 202 - <ShellStateProvider> 203 - <ModalStateProvider> 204 - <DialogStateProvider> 205 - <LightboxStateProvider> 206 - <PortalProvider> 207 - <StarterPackProvider> 208 - <InnerApp /> 209 - </StarterPackProvider> 210 - </PortalProvider> 211 - </LightboxStateProvider> 212 - </DialogStateProvider> 213 - </ModalStateProvider> 214 - </ShellStateProvider> 215 - </I18nProvider> 216 - </PrefsStateProvider> 217 - </SessionProvider> 208 + <AnalyticsContext> 209 + <SessionProvider> 210 + <PrefsStateProvider> 211 + <I18nProvider> 212 + <ShellStateProvider> 213 + <ModalStateProvider> 214 + <DialogStateProvider> 215 + <LightboxStateProvider> 216 + <PortalProvider> 217 + <StarterPackProvider> 218 + <InnerApp /> 219 + </StarterPackProvider> 220 + </PortalProvider> 221 + </LightboxStateProvider> 222 + </DialogStateProvider> 223 + </ModalStateProvider> 224 + </ShellStateProvider> 225 + </I18nProvider> 226 + </PrefsStateProvider> 227 + </SessionProvider> 228 + </AnalyticsContext> 218 229 </OnboardingProvider> 219 230 </A11yProvider> 220 231 </Geo.Provider>
+67 -81
src/Navigation.tsx
··· 28 28 storePayloadForAccountSwitch, 29 29 } from '#/lib/hooks/useNotificationHandler' 30 30 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 31 - import {logger as notyLogger} from '#/lib/notifications/util' 31 + import {useCallOnce} from '#/lib/once' 32 32 import {buildStateObject} from '#/lib/routes/helpers' 33 33 import { 34 34 type AllNavigatorParams, ··· 38 38 type MessagesTabNavigatorParams, 39 39 type MyProfileTabNavigatorParams, 40 40 type NotificationsTabNavigatorParams, 41 + type RouteParams, 41 42 type SearchTabNavigatorParams, 43 + type State, 42 44 } from '#/lib/routes/types' 43 - import {type RouteParams, type State} from '#/lib/routes/types' 44 - import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' 45 45 import {bskyTitle} from '#/lib/strings/headings' 46 - import {logger} from '#/logger' 47 46 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 48 47 import {useSession} from '#/state/session' 49 48 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 137 136 EmailDialogScreenID, 138 137 useEmailDialogControl, 139 138 } from '#/components/dialogs/EmailDialog' 139 + import {useAnalytics} from '#/analytics' 140 + import {setNavigationMetadata} from '#/analytics/metadata' 140 141 import {IS_NATIVE, IS_WEB} from '#/env' 141 142 import {router} from '#/routes' 142 143 import {Referrer} from '../modules/expo-bluesky-swiss-army' ··· 879 880 let lastHandledNotificationDateDedupe: number | undefined 880 881 881 882 function RoutesContainer({children}: React.PropsWithChildren<{}>) { 883 + const ax = useAnalytics() 884 + const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications) 882 885 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) 883 886 const {currentAccount, accounts} = useSession() 884 887 const {onPressSwitchAccount} = useAccountSwitcher() 885 888 const {setShowLoggedOut} = useLoggedOutViewControls() 886 - const prevLoggedRouteName = useRef<string | undefined>(undefined) 889 + const previousScreen = useRef<string | undefined>(undefined) 887 890 const emailDialogControl = useEmailDialogControl() 888 891 const closeAllActiveElements = useCloseAllActiveElements() 889 892 ··· 945 948 const payload = getNotificationPayload(response.notification) 946 949 947 950 if (payload) { 948 - notyLogger.metric( 949 - 'notifications:openApp', 950 - {reason: payload.reason, causedBoot: true}, 951 - {statsig: false}, 952 - ) 951 + ax.metric('notifications:openApp', { 952 + reason: payload.reason, 953 + causedBoot: true, 954 + }) 953 955 954 956 if (payload.reason === 'chat-message') { 955 957 handleChatMessage(payload) ··· 973 975 } 974 976 } 975 977 976 - function onReady() { 977 - prevLoggedRouteName.current = getCurrentRouteName() 978 + const onNavigationReady = useCallOnce(() => { 979 + const currentScreen = getCurrentRouteName() 980 + setNavigationMetadata({ 981 + previousScreen: currentScreen, 982 + currentScreen, 983 + }) 984 + previousScreen.current = currentScreen 985 + 986 + handlePushNotificationEntry() 987 + 988 + ax.metric('router:navigate', {}) 989 + 978 990 if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { 979 991 emailDialogControl.open({ 980 992 id: EmailDialogScreenID.VerificationReminder, 981 993 }) 982 994 snoozeEmailConfirmationPrompt() 983 995 } 984 - } 996 + 997 + ax.metric('init', { 998 + initMs: Math.round( 999 + // @ts-ignore Emitted by Metro in the bundle prelude 1000 + performance.now() - global.__BUNDLE_START_TIME__, 1001 + ), 1002 + }) 1003 + 1004 + if (IS_WEB) { 1005 + const referrerInfo = Referrer.getReferrerInfo() 1006 + if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 1007 + ax.metric('deepLink:referrerReceived', { 1008 + to: window.location.href, 1009 + referrer: referrerInfo?.referrer, 1010 + hostname: referrerInfo?.hostname, 1011 + }) 1012 + } 1013 + } 1014 + }) 985 1015 986 1016 return ( 987 - <> 988 - <NavigationContainer 989 - ref={navigationRef} 990 - linking={LINKING} 991 - theme={theme} 992 - onStateChange={() => { 993 - logger.metric( 994 - 'router:navigate', 995 - {from: prevLoggedRouteName.current}, 996 - {statsig: false}, 997 - ) 998 - prevLoggedRouteName.current = getCurrentRouteName() 999 - }} 1000 - onReady={() => { 1001 - attachRouteToLogEvents(getCurrentRouteName) 1002 - logModuleInitTime() 1003 - onReady() 1004 - logger.metric('router:navigate', {}, {statsig: false}) 1005 - handlePushNotificationEntry() 1006 - }} 1007 - // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x 1008 - // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. 1009 - // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. 1010 - // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. 1011 - // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x 1012 - // -sfn 1013 - navigationInChildEnabled> 1014 - {children} 1015 - </NavigationContainer> 1016 - </> 1017 + <NavigationContainer 1018 + ref={navigationRef} 1019 + linking={LINKING} 1020 + theme={theme} 1021 + onStateChange={() => { 1022 + const currentScreen = getCurrentRouteName() 1023 + // do this before metric 1024 + setNavigationMetadata({ 1025 + previousScreen: previousScreen.current, 1026 + currentScreen, 1027 + }) 1028 + ax.metric('router:navigate', {from: previousScreen.current}) 1029 + previousScreen.current = currentScreen 1030 + }} 1031 + onReady={onNavigationReady} 1032 + // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x 1033 + // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. 1034 + // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. 1035 + // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. 1036 + // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x 1037 + // -sfn 1038 + navigationInChildEnabled> 1039 + {children} 1040 + </NavigationContainer> 1017 1041 ) 1018 1042 } 1019 1043 ··· 1084 1108 ]) 1085 1109 } else { 1086 1110 return Promise.resolve() 1087 - } 1088 - } 1089 - 1090 - let didInit = false 1091 - function logModuleInitTime() { 1092 - if (didInit) { 1093 - return 1094 - } 1095 - didInit = true 1096 - 1097 - const initMs = Math.round( 1098 - // @ts-ignore Emitted by Metro in the bundle prelude 1099 - performance.now() - global.__BUNDLE_START_TIME__, 1100 - ) 1101 - console.log(`Time to first paint: ${initMs} ms`) 1102 - logEvent('init', { 1103 - initMs, 1104 - }) 1105 - 1106 - if (IS_WEB) { 1107 - const referrerInfo = Referrer.getReferrerInfo() 1108 - if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 1109 - logEvent('deepLink:referrerReceived', { 1110 - to: window.location.href, 1111 - referrer: referrerInfo?.referrer, 1112 - hostname: referrerInfo?.hostname, 1113 - }) 1114 - } 1115 - } 1116 - 1117 - if (__DEV__) { 1118 - // This log is noisy, so keep false committed 1119 - const shouldLog = false 1120 - // Relies on our patch to polyfill.js in metro-runtime 1121 - const initLogs = (global as any).__INIT_LOGS__ 1122 - if (shouldLog && Array.isArray(initLogs)) { 1123 - console.log(initLogs.join('\n')) 1124 - } 1125 1111 } 1126 1112 } 1127 1113
+9 -11
src/ageAssurance/components/NoAccessScreen.tsx
··· 9 9 useCreateSupportLink, 10 10 } from '#/lib/hooks/useCreateSupportLink' 11 11 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 12 - import {logger} from '#/logger' 13 12 import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' 14 13 import {useSessionApi} from '#/state/session' 15 14 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' ··· 36 35 isLegacyBirthdateBug, 37 36 useAgeAssuranceRegionConfig, 38 37 } from '#/ageAssurance/util' 39 - import {IS_WEB} from '#/env' 40 - import {IS_NATIVE} from '#/env' 38 + import {useAnalytics} from '#/analytics' 39 + import {IS_NATIVE, IS_WEB} from '#/env' 41 40 import {useDeviceGeolocationApi} from '#/geolocation' 42 41 43 42 const textStyles = [a.text_md, a.leading_snug] ··· 45 44 export function NoAccessScreen() { 46 45 const t = useTheme() 47 46 const {_} = useLingui() 47 + const ax = useAnalytics() 48 48 const {gtPhone} = useBreakpoints() 49 49 const insets = useSafeAreaInsets() 50 50 const birthdateControl = useDialogControl() ··· 63 63 64 64 useEffect(() => { 65 65 // just counting overall hits here 66 - logger.metric(`blockedGeoOverlay:shown`, {}) 67 - logger.metric(`ageAssurance:noAccessScreen:shown`, { 66 + ax.metric(`blockedGeoOverlay:shown`, {}) 67 + ax.metric(`ageAssurance:noAccessScreen:shown`, { 68 68 accountCreatedAt: data?.accountCreatedAt || 'unknown', 69 69 isAARegion, 70 70 hasDeclaredAge, ··· 103 103 label={_(msg`Click here to update your birthdate`)} 104 104 style={[textStyles]} 105 105 {...createStaticClick(() => { 106 - logger.metric( 107 - 'ageAssurance:noAccessScreen:openBirthdateDialog', 108 - {}, 109 - ) 106 + ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {}) 110 107 birthdateControl.open() 111 108 })}> 112 109 clicking here ··· 272 269 function AccessSection() { 273 270 const t = useTheme() 274 271 const {_, i18n} = useLingui() 272 + const ax = useAnalytics() 275 273 const control = useDialogControl() 276 274 const appealControl = Dialog.useDialogControl() 277 275 const locationControl = Dialog.useDialogControl() ··· 305 303 label={_(msg`Contact our moderation team`)} 306 304 {...createStaticClick(() => { 307 305 appealControl.open() 308 - logger.metric('ageAssurance:appealDialogOpen', {}) 306 + ax.metric('ageAssurance:appealDialogOpen', {}) 309 307 })}> 310 308 contact our moderation team 311 309 </SimpleInlineLinkText>{' '} ··· 321 319 color={hasInitiated ? 'secondary' : 'primary'} 322 320 onPress={() => { 323 321 control.open() 324 - logger.metric('ageAssurance:initDialogOpen', { 322 + ax.metric('ageAssurance:initDialogOpen', { 325 323 hasInitiatedPreviously: hasInitiated, 326 324 }) 327 325 }}>
+7 -7
src/ageAssurance/components/RedirectOverlay.tsx
··· 25 25 import {Loader} from '#/components/Loader' 26 26 import {Text} from '#/components/Typography' 27 27 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 28 - import {logger} from '#/ageAssurance' 29 - import {IS_WEB} from '#/env' 30 - import {IS_IOS} from '#/env' 28 + import {useAnalytics} from '#/analytics' 29 + import {IS_IOS, IS_WEB} from '#/env' 31 30 32 31 export type RedirectOverlayState = { 33 32 result: 'success' | 'unknown' ··· 174 173 175 174 function Inner() { 176 175 const t = useTheme() 176 + const ax = useAnalytics() 177 177 const {_} = useLingui() 178 178 const agent = useAgent() 179 179 const polling = useRef(false) ··· 187 187 188 188 polling.current = true 189 189 190 - logger.metric('ageAssurance:redirectDialogOpen', {}) 190 + ax.metric('ageAssurance:redirectDialogOpen', {}) 191 191 192 192 wait( 193 193 3e3, ··· 218 218 219 219 setSuccess(true) 220 220 221 - logger.metric('ageAssurance:redirectDialogSuccess', {}) 221 + ax.metric('ageAssurance:redirectDialogSuccess', {}) 222 222 }) 223 223 .catch(() => { 224 224 if (unmounted.current) return 225 225 setError(true) 226 - logger.metric('ageAssurance:redirectDialogFail', {}) 226 + ax.metric('ageAssurance:redirectDialogFail', {}) 227 227 }) 228 228 229 229 return () => { 230 230 unmounted.current = true 231 231 } 232 - }, [agent]) 232 + }, [ax, agent]) 233 233 234 234 if (success) { 235 235 return (
+7 -9
src/ageAssurance/useBeginAgeAssurance.ts
··· 12 12 import {useAgent} from '#/state/session' 13 13 import {usePatchAgeAssuranceServerState} from '#/ageAssurance' 14 14 import {logger} from '#/ageAssurance/logger' 15 + import {useAnalytics} from '#/analytics' 15 16 import {BLUESKY_PROXY_DID} from '#/env' 16 17 import {useGeolocation} from '#/geolocation' 17 18 ··· 19 20 const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW 20 21 21 22 export function useBeginAgeAssurance() { 23 + const ax = useAnalytics() 22 24 const agent = useAgent() 23 25 const geolocation = useGeolocation() 24 26 const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() ··· 48 50 appView.sessionManager.session.accessJwt = token 49 51 appView.sessionManager.session.refreshJwt = '' 50 52 51 - logger.metric( 52 - 'ageAssurance:api:begin', 53 - { 54 - platform: Platform.OS, 55 - countryCode, 56 - regionCode, 57 - }, 58 - {statsig: false}, 59 - ) 53 + ax.metric('ageAssurance:api:begin', { 54 + platform: Platform.OS, 55 + countryCode, 56 + regionCode, 57 + }) 60 58 61 59 /* 62 60 * 2s wait is good actually. Email sending takes a hot sec and this helps
+32
src/analytics/PassiveAnalytics.tsx
··· 1 + import {useEffect, useRef} from 'react' 2 + 3 + import {getCurrentState, onAppStateChange} from '#/lib/appState' 4 + import {useAnalytics} from '#/analytics' 5 + 6 + /** 7 + * Tracks passive analytics like app foreground/background time. 8 + */ 9 + export function PassiveAnalytics() { 10 + const ax = useAnalytics() 11 + const lastActive = useRef( 12 + getCurrentState() === 'active' ? performance.now() : null, 13 + ) 14 + 15 + useEffect(() => { 16 + const sub = onAppStateChange(state => { 17 + if (state === 'active') { 18 + lastActive.current = performance.now() 19 + ax.metric('state:foreground', {}) 20 + } else if (lastActive.current !== null) { 21 + ax.metric('state:background', { 22 + secondsActive: Math.round( 23 + (performance.now() - lastActive.current) / 1e3, 24 + ), 25 + }) 26 + } 27 + }) 28 + return () => sub.remove() 29 + }, [ax]) 30 + 31 + return null 32 + }
+62
src/analytics/features/index.ts
··· 1 + import {GrowthBook} from '@growthbook/growthbook-react' 2 + 3 + import {type Metadata} from '#/analytics/metadata' 4 + import * as env from '#/env' 5 + 6 + export {Features} from '#/analytics/features/types' 7 + 8 + /** 9 + * We vary the amount of time we wait for GrowthBook to fetch feature 10 + * gates based on the strategy specified. 11 + */ 12 + export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates' 13 + 14 + const TIMEOUT_INIT = 500 // TODO should base on p99 or something 15 + const TIMEOUT_PREFER_LOW_LATENCY = 250 16 + const TIMEOUT_PREFER_FRESH_GATES = 1500 17 + 18 + export const features = new GrowthBook({ 19 + apiHost: env.GROWTHBOOK_API_HOST, 20 + clientKey: env.GROWTHBOOK_CLIENT_KEY, 21 + }) 22 + 23 + /** 24 + * Initializer promise that must be awaited before using the GrowthBook 25 + * instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be 26 + * fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In 27 + * that case, we may see a flash of uncustomized content until the 28 + * initialization completes. 29 + */ 30 + export const init = new Promise<void>(async y => { 31 + await features.init({timeout: TIMEOUT_INIT}) 32 + y() 33 + }) 34 + 35 + /** 36 + * Refresh feature gates from GrowthBook. Updates attributes based on the 37 + * provided account, if any. 38 + */ 39 + export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) { 40 + await features.refreshFeatures({ 41 + timeout: 42 + strategy === 'prefer-low-latency' 43 + ? TIMEOUT_PREFER_LOW_LATENCY 44 + : TIMEOUT_PREFER_FRESH_GATES, 45 + }) 46 + } 47 + 48 + /** 49 + * Converts our metadata into GrowthBook attributes and sets them. 50 + */ 51 + export function setAttributes({base, session, preferences}: Metadata) { 52 + const {deviceId, sessionId, ...br} = base 53 + features.setAttributes({ 54 + device_id: deviceId, // GrowthBook special field 55 + session_id: sessionId, // GrowthBook special field 56 + user_id: session?.did, // GrowthBook special field 57 + id: session?.did, // GrowthBook special field 58 + ...br, 59 + ...(session || {}), 60 + ...(preferences || {}), 61 + }) 62 + }
+7
src/analytics/features/types.ts
··· 1 + export enum Features { 2 + DebugFeedContext = 'debug_show_feedcontext', 3 + IsBskyTeam = 'is_bsky_team_member', 4 + DisableOnboardingFindContacts = 'disable_onboarding_find_contacts', 5 + DisableSettingsFindContacts = 'disable_settings_find_contacts', 6 + DisableLiveNowBeta = 'disable_live_now_beta', 7 + }
+26
src/analytics/identifiers/device.ts
··· 1 + import uuid from 'react-native-uuid' 2 + import AsyncStorage from '@react-native-async-storage/async-storage' 3 + 4 + import {device} from '#/storage' 5 + 6 + const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID' 7 + 8 + export async function getAndMigrateDeviceId() { 9 + const migrated = getDeviceId() 10 + if (migrated) return migrated 11 + const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4() 12 + device.set(['deviceId'], id) 13 + return id 14 + } 15 + 16 + export function getDeviceId() { 17 + return device.get(['deviceId']) 18 + } 19 + 20 + export function getDeviceIdOrThrow() { 21 + const id = device.get(['deviceId']) 22 + if (!id) { 23 + throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`) 24 + } 25 + return id 26 + }
+2
src/analytics/identifiers/index.ts
··· 1 + export * from '#/analytics/identifiers/device' 2 + export * from '#/analytics/identifiers/session'
+79
src/analytics/identifiers/session.test.ts
··· 1 + jest.mock('#/storage', () => ({ 2 + device: { 3 + get: jest.fn(), 4 + set: jest.fn(), 5 + }, 6 + })) 7 + 8 + jest.mock('#/analytics/identifiers/util', () => ({ 9 + isSessionIdExpired: jest.fn(), 10 + })) 11 + 12 + jest.mock('#/lib/appState', () => ({ 13 + onAppStateChange: jest.fn(() => ({remove: jest.fn()})), 14 + })) 15 + 16 + beforeEach(() => { 17 + jest.resetModules() 18 + jest.clearAllMocks() 19 + }) 20 + 21 + function getMocks() { 22 + const {device} = require('#/storage') 23 + const {isSessionIdExpired} = require('#/analytics/identifiers/util') 24 + return { 25 + device: jest.mocked(device), 26 + isSessionIdExpired: jest.mocked(isSessionIdExpired), 27 + } 28 + } 29 + 30 + describe('session initialization', () => { 31 + it('creates new session and sets timestamp when none exists', () => { 32 + const {device, isSessionIdExpired} = getMocks() 33 + device.get.mockReturnValue(undefined) 34 + isSessionIdExpired.mockReturnValue(false) 35 + 36 + const {getInitialSessionId} = require('./session') 37 + const id = getInitialSessionId() 38 + 39 + expect(id).toBeDefined() 40 + expect(typeof id).toBe('string') 41 + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) 42 + expect(device.set).toHaveBeenCalledWith( 43 + ['nativeSessionIdLastEventAt'], 44 + expect.any(Number), 45 + ) 46 + }) 47 + 48 + it('reuses existing session when not expired', () => { 49 + const {device, isSessionIdExpired} = getMocks() 50 + const existingId = 'existing-session-id' 51 + device.get.mockImplementation((key: string[]) => { 52 + if (key[0] === 'nativeSessionId') return existingId 53 + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() 54 + return undefined 55 + }) 56 + isSessionIdExpired.mockReturnValue(false) 57 + 58 + const {getInitialSessionId} = require('./session') 59 + 60 + expect(getInitialSessionId()).toBe(existingId) 61 + }) 62 + 63 + it('creates new session when existing is expired', () => { 64 + const {device, isSessionIdExpired} = getMocks() 65 + const existingId = 'existing-session-id' 66 + device.get.mockImplementation((key: string[]) => { 67 + if (key[0] === 'nativeSessionId') return existingId 68 + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999 69 + return undefined 70 + }) 71 + isSessionIdExpired.mockReturnValue(true) 72 + 73 + const {getInitialSessionId} = require('./session') 74 + const id = getInitialSessionId() 75 + 76 + expect(id).not.toBe(existingId) 77 + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) 78 + }) 79 + })
+40
src/analytics/identifiers/session.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import uuid from 'react-native-uuid' 3 + 4 + import {onAppStateChange} from '#/lib/appState' 5 + import {isSessionIdExpired} from '#/analytics/identifiers/util' 6 + import {device} from '#/storage' 7 + 8 + let sessionId = (() => { 9 + const existing = device.get(['nativeSessionId']) 10 + const lastEvent = device.get(['nativeSessionIdLastEventAt']) 11 + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() 12 + device.set(['nativeSessionId'], id) 13 + device.set(['nativeSessionIdLastEventAt'], Date.now()) 14 + return id 15 + })() 16 + 17 + export function getInitialSessionId() { 18 + return sessionId 19 + } 20 + 21 + export function useSessionId() { 22 + const [id, setId] = useState(() => sessionId) 23 + 24 + useEffect(() => { 25 + const sub = onAppStateChange(state => { 26 + if (state === 'active') { 27 + const lastEvent = device.get(['nativeSessionIdLastEventAt']) 28 + if (isSessionIdExpired(lastEvent)) { 29 + sessionId = uuid.v4() 30 + device.set(['nativeSessionId'], sessionId) 31 + setId(sessionId) 32 + } 33 + } 34 + device.set(['nativeSessionIdLastEventAt'], Date.now()) 35 + }) 36 + return () => sub.remove() 37 + }, []) 38 + 39 + return id 40 + }
+44
src/analytics/identifiers/session.web.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import uuid from 'react-native-uuid' 3 + 4 + import {onAppStateChange} from '#/lib/appState' 5 + import {isSessionIdExpired} from '#/analytics/identifiers/util' 6 + 7 + const SESSION_ID_KEY = 'bsky_session_id' 8 + const LAST_EVENT_KEY = 'bsky_session_id_last_event_at' 9 + 10 + let sessionId = (() => { 11 + const existing = window.sessionStorage.getItem(SESSION_ID_KEY) 12 + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) 13 + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined 14 + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() 15 + window.sessionStorage.setItem(SESSION_ID_KEY, id) 16 + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) 17 + return id 18 + })() 19 + 20 + export function getInitialSessionId() { 21 + return sessionId 22 + } 23 + 24 + export function useSessionId() { 25 + const [id, setId] = useState(() => sessionId) 26 + 27 + useEffect(() => { 28 + const sub = onAppStateChange(state => { 29 + if (state === 'active') { 30 + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) 31 + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined 32 + if (isSessionIdExpired(lastEvent)) { 33 + sessionId = uuid.v4() 34 + window.sessionStorage.setItem(SESSION_ID_KEY, sessionId) 35 + setId(sessionId) 36 + } 37 + } 38 + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) 39 + }) 40 + return () => sub.remove() 41 + }, []) 42 + 43 + return id 44 + }
+9
src/analytics/identifiers/util.ts
··· 1 + import * as env from '#/env' 2 + 3 + const ONE_MIN = 60 * 1e3 4 + const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native 5 + 6 + export function isSessionIdExpired(since: number | undefined) { 7 + if (since === undefined) return false 8 + return Date.now() - since >= TTL 9 + }
+229
src/analytics/index.tsx
··· 1 + import {createContext, useContext, useEffect, useMemo} from 'react' 2 + import {Platform} from 'react-native' 3 + 4 + import {Logger} from '#/logger' 5 + import { 6 + Features, 7 + features as feats, 8 + init, 9 + refresh, 10 + setAttributes, 11 + } from '#/analytics/features' 12 + import { 13 + getAndMigrateDeviceId, 14 + getDeviceId, 15 + getInitialSessionId, 16 + useSessionId, 17 + } from '#/analytics/identifiers' 18 + import { 19 + getNavigationMetadata, 20 + type MergeableMetadata, 21 + type Metadata, 22 + } from '#/analytics/metadata' 23 + import {type Metrics, metrics} from '#/analytics/metrics' 24 + import * as refParams from '#/analytics/misc/refParams' 25 + import {getMetadataForLogger} from '#/analytics/utils' 26 + import * as env from '#/env' 27 + import {useGeolocation} from '#/geolocation' 28 + import {device} from '#/storage' 29 + 30 + export * as utils from '#/analytics/utils' 31 + export const features = {init, refresh} 32 + export {Features} from '#/analytics/features' 33 + export {type Metrics} from '#/analytics/metrics' 34 + 35 + type LoggerType = { 36 + debug: Logger['debug'] 37 + info: Logger['info'] 38 + log: Logger['log'] 39 + warn: Logger['warn'] 40 + error: Logger['error'] 41 + /** 42 + * Clones the existing logger and overrides the `context` value. Existing 43 + * metadata is inherited. 44 + * 45 + * ```ts 46 + * const ax = useAnalytics() 47 + * const logger = ax.logger.useChild(ax.logger.Context.Notifications) 48 + * ``` 49 + */ 50 + useChild: (context: Exclude<Logger['context'], undefined>) => LoggerType 51 + Context: typeof Logger.Context 52 + } 53 + export type AnalyticsContextType = { 54 + metadata: Metadata 55 + logger: LoggerType 56 + metric: <E extends keyof Metrics>( 57 + event: E, 58 + payload: Metrics[E], 59 + metadata?: MergeableMetadata, 60 + ) => void 61 + features: typeof Features & { 62 + enabled(feature: Features): boolean 63 + } 64 + } 65 + export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'> 66 + 67 + function createLogger( 68 + context: Logger['context'], 69 + metadata: Partial<Metadata>, 70 + ): LoggerType { 71 + const logger = Logger.create(context, metadata) 72 + return { 73 + debug: logger.debug.bind(logger), 74 + info: logger.info.bind(logger), 75 + log: logger.log.bind(logger), 76 + warn: logger.warn.bind(logger), 77 + error: logger.error.bind(logger), 78 + useChild: (context: Exclude<Logger['context'], undefined>) => { 79 + return useMemo(() => createLogger(context, metadata), [context, metadata]) 80 + }, 81 + Context: Logger.Context, 82 + } 83 + } 84 + 85 + const Context = createContext<AnalyticsBaseContextType>({ 86 + logger: createLogger(Logger.Context.Default, {}), 87 + metric: (event, payload, metadata) => { 88 + if (metadata && '__meta' in metadata) { 89 + delete metadata.__meta 90 + } 91 + metrics.track(event, payload, { 92 + ...metadata, 93 + navigation: getNavigationMetadata(), 94 + }) 95 + }, 96 + metadata: { 97 + base: { 98 + deviceId: getDeviceId() ?? 'unknown', 99 + sessionId: getInitialSessionId(), 100 + platform: Platform.OS, 101 + appVersion: env.APP_VERSION, 102 + bundleIdentifier: env.BUNDLE_IDENTIFIER, 103 + bundleDate: env.BUNDLE_DATE, 104 + referrerSrc: refParams.src, 105 + referrerUrl: refParams.url, 106 + }, 107 + geolocation: device.get(['mergedGeolocation']) || { 108 + countryCode: '', 109 + regionCode: '', 110 + }, 111 + }, 112 + }) 113 + 114 + /** 115 + * Ensures that deviceId is set and migrated from legacy storage. Handled on 116 + * startup in `App.<platform>.tsx`. This must be awaited prior to the app 117 + * booting up. 118 + */ 119 + export const setupDeviceId = getAndMigrateDeviceId() 120 + 121 + /** 122 + * Analytics context provider. Decorates the parent analytics context with 123 + * additional metadata. Nesting should be done carefully and sparingly. 124 + */ 125 + export function AnalyticsContext({ 126 + children, 127 + metadata, 128 + }: { 129 + children: React.ReactNode 130 + metadata?: MergeableMetadata 131 + }) { 132 + if (metadata) { 133 + if (!('__meta' in metadata)) { 134 + throw new Error( 135 + 'Use the useMeta() helper when passing metadata to AnalyticsContext', 136 + ) 137 + } 138 + } 139 + const sessionId = useSessionId() 140 + const geolocation = useGeolocation() 141 + const parentContext = useContext(Context) 142 + const childContext = useMemo(() => { 143 + const combinedMetadata = { 144 + ...parentContext.metadata, 145 + ...metadata, 146 + base: { 147 + ...parentContext.metadata.base, 148 + sessionId, 149 + }, 150 + geolocation, 151 + } 152 + const context: AnalyticsBaseContextType = { 153 + ...parentContext, 154 + logger: createLogger( 155 + Logger.Context.Default, 156 + getMetadataForLogger(combinedMetadata), 157 + ), 158 + metadata: combinedMetadata, 159 + metric: (event, payload, extraMetadata) => { 160 + parentContext.metric(event, payload, { 161 + ...combinedMetadata, 162 + ...extraMetadata, 163 + }) 164 + }, 165 + } 166 + return context 167 + }, [sessionId, geolocation, parentContext, metadata]) 168 + return <Context.Provider value={childContext}>{children}</Context.Provider> 169 + } 170 + 171 + /** 172 + * Feature gates provider. Decorates the parent analytics context with 173 + * feature gate capabilities. Should be mounted within `AnalyticsContext`, 174 + * and below the `<Fragment key={did} />` breaker in `App.<platform>.tsx`. 175 + */ 176 + export function AnalyticsFeaturesContext({ 177 + children, 178 + }: { 179 + children: React.ReactNode 180 + }) { 181 + const parentContext = useContext(Context) 182 + 183 + useEffect(() => { 184 + feats.setTrackingCallback((experiment, result) => { 185 + parentContext.metric('experiment:viewed', { 186 + experimentId: experiment.key, 187 + variationId: result.key, 188 + }) 189 + }) 190 + }, [parentContext.metric]) 191 + 192 + useEffect(() => { 193 + setAttributes(parentContext.metadata) 194 + }, [parentContext.metadata]) 195 + 196 + const childContext = useMemo<AnalyticsContextType>(() => { 197 + return { 198 + ...parentContext, 199 + features: { 200 + enabled: feats.isOn.bind(feats), 201 + ...Features, 202 + }, 203 + } 204 + }, [parentContext]) 205 + 206 + return <Context.Provider value={childContext}>{children}</Context.Provider> 207 + } 208 + 209 + /** 210 + * Basic analytics context without feature gates. Should really only be used 211 + * above the `AnalyticsFeaturesContext` provider. 212 + */ 213 + export function useAnalyticsBase() { 214 + return useContext(Context) 215 + } 216 + 217 + /** 218 + * The main analytics context, including feature gates. Use this everywhere you 219 + * need metrics, features, or logging within the React tree. 220 + */ 221 + export function useAnalytics() { 222 + const ctx = useContext(Context) 223 + if (!('features' in ctx)) { 224 + throw new Error( 225 + 'useAnalytics must be used within an AnalyticsFeaturesContext', 226 + ) 227 + } 228 + return ctx as AnalyticsContextType 229 + }
+61
src/analytics/metadata.ts
··· 1 + import {type Geolocation} from '#/geolocation' 2 + 3 + export type BaseMetadata = { 4 + deviceId: string 5 + sessionId: string 6 + platform: string 7 + appVersion: string 8 + bundleIdentifier: string 9 + bundleDate: number 10 + referrerSrc: string 11 + referrerUrl: string 12 + } 13 + 14 + export type GeolocationMetadata = Geolocation 15 + 16 + export type SessionMetadata = { 17 + did: string 18 + isBskyPds: boolean 19 + } 20 + 21 + export type PreferencesMetadata = { 22 + appLanguage: string 23 + contentLanguages: string[] 24 + } 25 + 26 + export type MergeableMetadata = { 27 + session?: SessionMetadata 28 + preferences?: PreferencesMetadata 29 + /** 30 + * Navigation metadata is not actually available on this object, instead it's 31 + * merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details. 32 + */ 33 + navigation?: NavigationMetadata 34 + } 35 + 36 + export type Metadata = { 37 + base: BaseMetadata 38 + geolocation: GeolocationMetadata 39 + } & MergeableMetadata 40 + 41 + /* 42 + * Navigation metadata is handle out-of-band from React, since we don't want to 43 + * slow down screen transitions in any way, and there doesn't seem to be a nice 44 + * way to get current navigation state without an additional re-render between 45 + * navigations. 46 + * 47 + * So instead of this data being available on the Metadata object, it's stored 48 + * here and merged in at time-of-log/metric. 49 + */ 50 + export type NavigationMetadata = { 51 + previousScreen?: string 52 + currentScreen?: string 53 + } 54 + let navigationMetadata: NavigationMetadata | undefined 55 + export function getNavigationMetadata() { 56 + console.log('metadata', JSON.stringify(navigationMetadata, null, 2)) 57 + return navigationMetadata 58 + } 59 + export function setNavigationMetadata(meta: NavigationMetadata | undefined) { 60 + navigationMetadata = meta 61 + }
+176
src/analytics/metrics/client.test.ts
··· 1 + import {MetricsClient} from './client' 2 + 3 + let appStateCallback: (state: string) => void 4 + 5 + jest.mock('#/lib/appState', () => ({ 6 + onAppStateChange: jest.fn(cb => { 7 + appStateCallback = cb 8 + return {remove: jest.fn()} 9 + }), 10 + })) 11 + 12 + jest.mock('#/logger', () => ({ 13 + Logger: { 14 + create: () => ({ 15 + info: jest.fn(), 16 + debug: jest.fn(), 17 + error: jest.fn(), 18 + }), 19 + Context: {Metric: 'metric'}, 20 + }, 21 + })) 22 + 23 + jest.mock('#/env', () => ({ 24 + METRICS_API_HOST: 'https://test.metrics.api', 25 + IS_WEB: false, 26 + })) 27 + 28 + type TestEvents = { 29 + click: {button: string} 30 + view: {screen: string} 31 + } 32 + 33 + describe('MetricsClient', () => { 34 + let fetchMock: jest.Mock 35 + let fetchRequests: {body: any}[] 36 + 37 + beforeEach(() => { 38 + jest.useFakeTimers({advanceTimers: true}) 39 + fetchRequests = [] 40 + fetchMock = jest.fn().mockImplementation(async (_url, options) => { 41 + const body = JSON.parse(options.body) 42 + fetchRequests.push({body}) 43 + return {ok: true, status: 200} 44 + }) 45 + global.fetch = fetchMock 46 + }) 47 + 48 + afterEach(() => { 49 + jest.useRealTimers() 50 + jest.clearAllMocks() 51 + }) 52 + 53 + it('flushes events on interval', async () => { 54 + const client = new MetricsClient<TestEvents>() 55 + client.track('click', {button: 'submit'}) 56 + client.track('view', {screen: 'home'}) 57 + 58 + expect(fetchRequests).toHaveLength(0) 59 + 60 + // Advance past the 10 second interval 61 + await jest.advanceTimersByTimeAsync(10_000) 62 + 63 + expect(fetchRequests).toHaveLength(1) 64 + expect(fetchRequests[0].body.events).toHaveLength(2) 65 + expect(fetchRequests[0].body.events[0].event).toBe('click') 66 + expect(fetchRequests[0].body.events[1].event).toBe('view') 67 + }) 68 + 69 + it('flushes when maxBatchSize is exceeded', async () => { 70 + const client = new MetricsClient<TestEvents>() 71 + client.maxBatchSize = 5 72 + 73 + // Add events up to maxBatchSize (should not flush yet) 74 + for (let i = 0; i < 5; i++) { 75 + client.track('click', {button: `btn-${i}`}) 76 + } 77 + 78 + expect(fetchRequests).toHaveLength(0) 79 + 80 + // One more event should trigger flush (> maxBatchSize) 81 + client.track('click', {button: 'btn-trigger'}) 82 + 83 + // Allow microtasks to run 84 + await jest.advanceTimersByTimeAsync(0) 85 + 86 + expect(fetchRequests).toHaveLength(1) 87 + expect(fetchRequests[0].body.events).toHaveLength(6) 88 + }) 89 + 90 + it('retries failed events once on 500 response', async () => { 91 + let requestCount = 0 92 + 93 + fetchMock.mockImplementation(async (_url, options) => { 94 + requestCount++ 95 + const body = JSON.parse(options.body) 96 + 97 + if (requestCount === 1) { 98 + // First request fails with 500 - "Failed to fetch" triggers isNetworkError 99 + return { 100 + ok: false, 101 + status: 500, 102 + text: async () => 'Internal Server Error', 103 + } 104 + } 105 + 106 + // Retry succeeds 107 + fetchRequests.push({body}) 108 + return {ok: true, status: 200} 109 + }) 110 + 111 + const client = new MetricsClient<TestEvents>() 112 + client.track('click', {button: 'submit'}) 113 + 114 + // Trigger flush via interval 115 + await jest.advanceTimersByTimeAsync(10_000) 116 + 117 + expect(requestCount).toBe(1) 118 + expect(fetchRequests).toHaveLength(0) 119 + 120 + // Simulate app coming to foreground to trigger retry 121 + appStateCallback('active') 122 + await jest.advanceTimersByTimeAsync(0) 123 + 124 + expect(requestCount).toBe(2) 125 + expect(fetchRequests).toHaveLength(1) 126 + expect(fetchRequests[0].body.events).toHaveLength(1) 127 + expect(fetchRequests[0].body.events[0].event).toBe('click') 128 + }) 129 + 130 + it('does not retry more than once', async () => { 131 + let requestCount = 0 132 + 133 + fetchMock.mockImplementation(async () => { 134 + requestCount++ 135 + // Always fail with network-like error 136 + return { 137 + ok: false, 138 + status: 500, 139 + text: async () => 'Internal Server Error', 140 + } 141 + }) 142 + 143 + const client = new MetricsClient<TestEvents>() 144 + client.track('click', {button: 'submit'}) 145 + 146 + // First flush fails 147 + await jest.advanceTimersByTimeAsync(10_000) 148 + 149 + expect(requestCount).toBe(1) 150 + 151 + // Retry also fails 152 + appStateCallback('active') 153 + await jest.advanceTimersByTimeAsync(0) 154 + 155 + expect(requestCount).toBe(2) 156 + 157 + // Another foreground event should not retry again (events are dropped) 158 + appStateCallback('active') 159 + await jest.advanceTimersByTimeAsync(0) 160 + 161 + expect(requestCount).toBe(2) // No additional requests 162 + }) 163 + 164 + it('flushes when app goes to background', async () => { 165 + const client = new MetricsClient<TestEvents>() 166 + client.track('click', {button: 'submit'}) 167 + 168 + expect(fetchRequests).toHaveLength(0) 169 + 170 + // Simulate app going to background 171 + appStateCallback('background') 172 + await jest.advanceTimersByTimeAsync(0) 173 + 174 + expect(fetchRequests).toHaveLength(1) 175 + }) 176 + })
+116
src/analytics/metrics/client.ts
··· 1 + import {onAppStateChange} from '#/lib/appState' 2 + import {isNetworkError} from '#/lib/strings/errors' 3 + import {Logger} from '#/logger' 4 + import * as env from '#/env' 5 + 6 + type Event<M extends Record<string, any>> = { 7 + time: number 8 + event: keyof M 9 + payload: M[keyof M] 10 + metadata: Record<string, any> 11 + } 12 + 13 + const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t' 14 + const logger = Logger.create(Logger.Context.Metric, {}) 15 + 16 + export class MetricsClient<M extends Record<string, any>> { 17 + maxBatchSize = 100 18 + 19 + private started: boolean = false 20 + private queue: Event<M>[] = [] 21 + private failedQueue: Event<M>[] = [] 22 + private flushInterval: NodeJS.Timeout | null = null 23 + 24 + start() { 25 + if (this.started) return 26 + this.started = true 27 + this.flushInterval = setInterval(() => { 28 + this.flush() 29 + }, 10_000) 30 + onAppStateChange(state => { 31 + if (state === 'active') { 32 + this.retryFailedLogs() 33 + } else { 34 + this.flush() 35 + } 36 + }) 37 + } 38 + 39 + track<E extends keyof M>( 40 + event: E, 41 + payload: M[E], 42 + metadata: Record<string, any> = {}, 43 + ) { 44 + this.start() 45 + 46 + const e = { 47 + time: Date.now(), 48 + event, 49 + payload, 50 + metadata, 51 + } 52 + this.queue.push(e) 53 + 54 + logger.info(`event: ${e.event as string}`, e) 55 + 56 + if (this.queue.length > this.maxBatchSize) { 57 + this.flush() 58 + } 59 + } 60 + 61 + flush() { 62 + if (!this.queue.length) return 63 + const events = this.queue.splice(0, this.queue.length) 64 + this.sendBatch(events) 65 + } 66 + 67 + private async sendBatch(events: Event<M>[], isRetry: boolean = false) { 68 + logger.debug(`sendBatch: ${events.length}`, { 69 + isRetry, 70 + }) 71 + 72 + try { 73 + const body = JSON.stringify({events}) 74 + if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { 75 + const success = navigator.sendBeacon( 76 + TRACKING_ENDPOINT, 77 + new Blob([body], {type: 'application/json'}), 78 + ) 79 + if (!success) { 80 + // construct a "network error" for `isNetworkError` to work 81 + throw new Error(`Failed to fetch: sendBeacon returned false`) 82 + } 83 + } else { 84 + const res = await fetch(TRACKING_ENDPOINT, { 85 + method: 'POST', 86 + headers: { 87 + 'Content-Type': 'application/json', 88 + }, 89 + body: JSON.stringify({events}), 90 + keepalive: true, 91 + }) 92 + 93 + if (!res.ok) { 94 + const error = await res.text().catch(() => 'Unknown error') 95 + // construct a "network error" for `isNetworkError` to work 96 + throw new Error(`${res.status} Failed to fetch — ${error}`) 97 + } 98 + } 99 + } catch (e: any) { 100 + if (isNetworkError(e)) { 101 + if (isRetry) return // retry once 102 + this.failedQueue.push(...events) 103 + return 104 + } 105 + logger.error(`Failed to send metrics`, { 106 + safeMessage: e.toString(), 107 + }) 108 + } 109 + } 110 + 111 + private retryFailedLogs() { 112 + if (!this.failedQueue.length) return 113 + const events = this.failedQueue.splice(0, this.failedQueue.length) 114 + this.sendBatch(events, true) 115 + } 116 + }
+6
src/analytics/metrics/index.ts
··· 1 + import {MetricsClient} from '#/analytics/metrics/client' 2 + import {type Events} from '#/analytics/metrics/types' 3 + 4 + export type {Events as Metrics} from '#/analytics/metrics/types' 5 + export * from '#/analytics/metrics/utils' 6 + export const metrics = new MetricsClient<Events>()
+7
src/analytics/metrics/utils.ts
··· 1 + export function toClout(n: number | null | undefined): number | undefined { 2 + if (n == null) { 3 + return undefined 4 + } else { 5 + return Math.max(0, Math.round(Math.log(n))) 6 + } 7 + }
+18
src/analytics/misc/refParams.ts
··· 1 + /** 2 + * This is used for our own Bluesky post embeds, and maybe other things. 3 + * 4 + * In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used. 5 + */ 6 + 7 + import * as env from '#/env' 8 + 9 + let refSrc = '' 10 + let refUrl = '' 11 + if (env.IS_WEB) { 12 + const params = new URLSearchParams(window.location.search) 13 + refSrc = params.get('ref_src') ?? '' 14 + refUrl = decodeURIComponent(params.get('ref_url') ?? '') 15 + } 16 + 17 + export const src = refSrc 18 + export const url = refUrl
+50
src/analytics/utils.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {BSKY_SERVICE} from '#/lib/constants' 4 + import {type SessionAccount} from '#/state/session' 5 + import { 6 + type MergeableMetadata, 7 + type Metadata, 8 + type SessionMetadata, 9 + } from '#/analytics/metadata' 10 + 11 + /** 12 + * Thin `useMemo` wrapper that marks the metadata as memoized and provides a 13 + * type guard. 14 + */ 15 + export function useMeta(metadata?: MergeableMetadata) { 16 + const m = useMemo(() => metadata, [metadata]) 17 + if (!m) return 18 + // @ts-ignore 19 + m.__meta = true 20 + return m 21 + } 22 + 23 + export function accountToSessionMetadata( 24 + account: SessionAccount | undefined, 25 + ): SessionMetadata | undefined { 26 + if (!account) { 27 + return 28 + } else { 29 + return { 30 + did: account.did, 31 + isBskyPds: account.service.startsWith(BSKY_SERVICE), 32 + } 33 + } 34 + } 35 + 36 + export function getMetadataForLogger({ 37 + base, 38 + geolocation, 39 + session, 40 + }: Metadata): Record<string, any> { 41 + return { 42 + deviceId: base.deviceId, 43 + sessionId: base.sessionId, 44 + platform: base.platform, 45 + appVersion: base.appVersion, 46 + countryCode: geolocation.countryCode, 47 + regionCode: geolocation.regionCode, 48 + isBskyPds: session?.isBskyPds || 'anonymous', 49 + } 50 + }
+24 -29
src/components/FeedInterstitials.tsx
··· 7 7 import {useNavigation} from '@react-navigation/native' 8 8 9 9 import {type NavigationProp} from '#/lib/routes/types' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 - import {logger} from '#/logger' 12 - import {type MetricEvents} from '#/logger/metrics' 13 10 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 11 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 15 12 import {type FeedDescriptor} from '#/state/queries/post-feed' ··· 38 35 import {InlineLinkText} from '#/components/Link' 39 36 import * as ProfileCard from '#/components/ProfileCard' 40 37 import {Text} from '#/components/Typography' 38 + import {type Metrics, useAnalytics} from '#/analytics' 41 39 import {IS_IOS} from '#/env' 42 40 import type * as bsky from '#/types/bsky' 43 41 import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' ··· 434 432 isVisible?: boolean 435 433 }) { 436 434 const t = useTheme() 435 + const ax = useAnalytics() 437 436 const {_} = useLingui() 438 437 const moderationOpts = useModerationOpts() 439 438 const {gtMobile} = useBreakpoints() ··· 450 449 const seenProfilesRef = useRef<Set<string>>(new Set()) 451 450 const containerRef = useRef<View>(null) 452 451 const hasTrackedRef = useRef(false) 453 - const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 454 - isFeedContext 455 - ? 'InterstitialDiscover' 456 - : isProfileHeaderContext 457 - ? 'Profile' 458 - : 'InterstitialProfile' 452 + const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext 453 + ? 'InterstitialDiscover' 454 + : isProfileHeaderContext 455 + ? 'Profile' 456 + : 'InterstitialProfile' 459 457 460 458 // Callback to fire seen events 461 459 const fireSeen = useCallback(() => { ··· 467 465 profilesToShow.forEach((profile, index) => { 468 466 if (!seenProfilesRef.current.has(profile.did)) { 469 467 seenProfilesRef.current.add(profile.did) 470 - logger.metric( 471 - 'suggestedUser:seen', 472 - { 473 - logContext, 474 - recId, 475 - position: index, 476 - suggestedDid: profile.did, 477 - category: null, 478 - }, 479 - {statsig: true}, 480 - ) 468 + ax.metric('suggestedUser:seen', { 469 + logContext, 470 + recId, 471 + position: index, 472 + suggestedDid: profile.did, 473 + category: null, 474 + }) 481 475 } 482 476 }) 483 - }, [isLoading, error, profiles, maxLength, logContext, recId]) 477 + }, [ax, isLoading, error, profiles, maxLength, logContext, recId]) 484 478 485 479 // For profile header, fire when isVisible becomes true 486 480 useEffect(() => { ··· 565 559 <ProfileCard.Link 566 560 profile={profile} 567 561 onPress={() => { 568 - logEvent('suggestedUser:press', { 562 + ax.metric('suggestedUser:press', { 569 563 logContext: isFeedContext 570 564 ? 'InterstitialDiscover' 571 565 : 'InterstitialProfile', ··· 588 582 onPress={e => { 589 583 e.preventDefault() 590 584 onDismiss(profile.did) 591 - logEvent('suggestedUser:dismiss', { 585 + ax.metric('suggestedUser:dismiss', { 592 586 logContext: isFeedContext 593 587 ? 'InterstitialDiscover' 594 588 : 'InterstitialProfile', ··· 656 650 withIcon={false} 657 651 style={[a.rounded_sm]} 658 652 onFollow={() => { 659 - logEvent('suggestedUser:follow', { 653 + ax.metric('suggestedUser:follow', { 660 654 logContext: isFeedContext 661 655 ? 'InterstitialDiscover' 662 656 : 'InterstitialProfile', ··· 678 672 // Use totalProfileCount (before dismissals) for minLength check on initial render. 679 673 const profileCountForMinCheck = totalProfileCount ?? profiles.length 680 674 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 681 - logger.debug(`Not enough profiles to show suggested follows`) 675 + ax.logger.debug(`Not enough profiles to show suggested follows`) 682 676 return null 683 677 } 684 678 ··· 712 706 label={_(msg`See more suggested profiles`)} 713 707 onPress={() => { 714 708 followDialogControl.open() 715 - logEvent('suggestedUser:seeMore', { 709 + ax.metric('suggestedUser:seeMore', { 716 710 logContext: isFeedContext ? 'Explore' : 'Profile', 717 711 }) 718 712 }}> ··· 756 750 <SeeMoreSuggestedProfilesCard 757 751 onPress={() => { 758 752 followDialogControl.open() 759 - logger.metric('suggestedUser:seeMore', { 753 + ax.metric('suggestedUser:seeMore', { 760 754 logContext: 'Explore', 761 755 }) 762 756 }} ··· 794 788 ) 795 789 } 796 790 791 + const numFeedsToDisplay = 3 797 792 export function SuggestedFeeds() { 798 - const numFeedsToDisplay = 3 799 793 const t = useTheme() 794 + const ax = useAnalytics() 800 795 const {_} = useLingui() 801 796 const {data, isLoading, error} = useGetPopularFeedsQuery({ 802 797 limit: numFeedsToDisplay, ··· 829 824 key={feed.uri} 830 825 view={feed} 831 826 onPress={() => { 832 - logEvent('feed:interstitial:feedCard:press', {}) 827 + ax.metric('feed:interstitial:feedCard:press', {}) 833 828 }}> 834 829 {({hovered, pressed}) => ( 835 830 <CardOuter
+4 -3
src/components/PostControls/BookmarkButton.tsx
··· 6 6 import type React from 'react' 7 7 8 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 - import {logger} from '#/logger' 10 9 import {type Shadow} from '#/state/cache/post-shadow' 11 10 import {useFeedFeedbackContext} from '#/state/feed-feedback' 12 11 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' ··· 15 14 import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 16 15 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 17 16 import * as toast from '#/components/Toast' 17 + import {useAnalytics} from '#/analytics' 18 18 import {PostControlButton, PostControlButtonIcon} from './PostControlButton' 19 19 20 20 export const BookmarkButton = memo(function BookmarkButton({ ··· 29 29 hitSlop?: Insets 30 30 }): React.ReactNode { 31 31 const t = useTheme() 32 + const ax = useAnalytics() 32 33 const {_} = useLingui() 33 34 const {mutateAsync: bookmark} = useBookmarkMutation() 34 35 const cleanError = useCleanError() ··· 52 53 post, 53 54 }) 54 55 55 - logger.metric('post:bookmark', { 56 + ax.metric('post:bookmark', { 56 57 uri: post.uri, 57 58 authorDid: post.author.did, 58 59 logContext, ··· 92 93 uri: post.uri, 93 94 }) 94 95 95 - logger.metric('post:unbookmark', { 96 + ax.metric('post:unbookmark', { 96 97 uri: post.uri, 97 98 authorDid: post.author.did, 98 99 logContext,
+3 -3
src/components/PostControls/DiscoverDebug.tsx
··· 3 3 import {t} from '@lingui/macro' 4 4 5 5 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 6 - import {useGate} from '#/lib/statsig/statsig' 7 6 import {useSession} from '#/state/session' 8 7 import {atoms as a, useTheme} from '#/alf' 9 8 import * as Toast from '#/components/Toast' 10 9 import {Text} from '#/components/Typography' 10 + import {useAnalytics} from '#/analytics' 11 11 import {IS_INTERNAL} from '#/env' 12 12 13 13 export function DiscoverDebug({ ··· 15 15 }: { 16 16 feedContext: string | undefined 17 17 }) { 18 + const ax = useAnalytics() 18 19 const {currentAccount} = useSession() 19 - const gate = useGate() 20 20 const isDiscoverDebugUser = 21 21 IS_INTERNAL || 22 22 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 23 - gate('debug_show_feedcontext') 23 + ax.features.enabled(ax.features.DebugFeedContext) 24 24 const theme = useTheme() 25 25 26 26 return (
+28 -25
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 26 26 type CommonNavigatorParams, 27 27 type NavigationProp, 28 28 } from '#/lib/routes/types' 29 - import {logEvent, useGate} from '#/lib/statsig/statsig' 30 29 import {richTextToString} from '#/lib/strings/rich-text-helpers' 31 30 import {toShareUrl} from '#/lib/strings/url-helpers' 32 31 import {logger} from '#/logger' 33 32 import {type Shadow} from '#/state/cache/post-shadow' 34 33 import {useProfileShadow} from '#/state/cache/profile-shadow' 35 34 import {useFeedFeedbackContext} from '#/state/feed-feedback' 36 - import {useLanguagePrefs} from '#/state/preferences' 37 - import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' 35 + import { 36 + useHiddenPosts, 37 + useHiddenPostsApi, 38 + useLanguagePrefs, 39 + } from '#/state/preferences' 38 40 import {usePinnedPostMutation} from '#/state/queries/pinned-post' 39 41 import { 40 42 usePostDeleteMutation, ··· 71 73 import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' 72 74 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 73 75 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 74 - import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 75 - import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 76 + import { 77 + Mute_Stroke2_Corner0_Rounded as Mute, 78 + Mute_Stroke2_Corner0_Rounded as MuteIcon, 79 + } from '#/components/icons/Mute' 76 80 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' 77 81 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 78 82 import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 79 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 80 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 83 + import { 84 + SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute, 85 + SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon, 86 + } from '#/components/icons/Speaker' 81 87 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 82 88 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 83 89 import {Loader} from '#/components/Loader' ··· 87 93 useReportDialogControl, 88 94 } from '#/components/moderation/ReportDialog' 89 95 import * as Prompt from '#/components/Prompt' 96 + import {useAnalytics} from '#/analytics' 90 97 import {IS_INTERNAL} from '#/env' 91 98 import * as bsky from '#/types/bsky' 92 99 ··· 116 123 }): React.ReactNode => { 117 124 const {hasSession, currentAccount} = useSession() 118 125 const {_} = useLingui() 126 + const ax = useAnalytics() 119 127 const langPrefs = useLanguagePrefs() 120 128 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() 121 129 const {mutateAsync: pinPostMutate, isPending: isPinPending} = ··· 212 220 try { 213 221 if (isThreadMuted) { 214 222 unmuteThread() 215 - logger.metric('post:unmute', { 223 + ax.metric('post:unmute', { 216 224 uri: postUri, 217 225 authorDid: postAuthor.did, 218 226 logContext, ··· 221 229 Toast.show(_(msg`You will now receive notifications for this thread`)) 222 230 } else { 223 231 muteThread() 224 - logger.metric('post:mute', { 232 + ax.metric('post:mute', { 225 233 uri: postUri, 226 234 authorDid: postAuthor.did, 227 235 logContext, ··· 258 266 AppBskyFeedPost.isRecord, 259 267 ) 260 268 ) { 261 - logger.metric( 262 - 'translate', 263 - { 264 - sourceLanguages: post.record.langs ?? [], 265 - targetLanguage: langPrefs.primaryLanguage, 266 - textLength: post.record.text.length, 267 - }, 268 - {statsig: false}, 269 - ) 269 + ax.metric('translate', { 270 + sourceLanguages: post.record.langs ?? [], 271 + targetLanguage: langPrefs.primaryLanguage, 272 + textLength: post.record.text.length, 273 + }) 270 274 } 271 275 } 272 276 273 277 const onHidePost = () => { 274 278 hidePost({uri: postUri}) 275 - logEvent('thread:click:hideReplyForMe', {}) 279 + ax.metric('thread:click:hideReplyForMe', {}) 276 280 } 277 281 278 282 const hideInPWI = !!postAuthor.labels?.find( ··· 286 290 feedContext: postFeedContext, 287 291 reqId: postReqId, 288 292 }) 289 - logger.metric('post:showMore', { 293 + ax.metric('post:showMore', { 290 294 uri: postUri, 291 295 authorDid: postAuthor.did, 292 296 logContext, ··· 304 308 feedContext: postFeedContext, 305 309 reqId: postReqId, 306 310 }) 307 - logger.metric('post:showLess', { 311 + ax.metric('post:showLess', { 308 312 uri: postUri, 309 313 authorDid: postAuthor.did, 310 314 logContext, ··· 368 372 369 373 // Log metric only when hiding (not when showing) 370 374 if (isHide) { 371 - logEvent('thread:click:hideReplyForEveryone', {}) 375 + ax.metric('thread:click:hideReplyForEveryone', {}) 372 376 } 373 377 374 378 Toast.show( ··· 405 409 } 406 410 407 411 const onPressPin = () => { 408 - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) 412 + ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 409 413 pinPostMutate({ 410 414 postUri, 411 415 postCid, ··· 458 462 459 463 const onSignIn = () => requireSignIn(() => {}) 460 464 461 - const gate = useGate() 462 465 const isDiscoverDebugUser = 463 466 IS_INTERNAL || 464 467 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 465 - gate('debug_show_feedcontext') 468 + ax.features.enabled(ax.features.DebugFeedContext) 466 469 467 470 return ( 468 471 <>
+3 -2
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 8 8 import {type NavigationProp} from '#/lib/routes/types' 9 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 10 import {sanitizeHandle} from '#/lib/strings/handles' 11 - import {logger} from '#/logger' 12 11 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 13 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' ··· 20 19 import {Text} from '#/components/Typography' 21 20 import {useSimpleVerificationState} from '#/components/verification' 22 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 22 + import {useAnalytics} from '#/analytics' 23 23 import type * as bsky from '#/types/bsky' 24 24 25 25 export function RecentChats({postUri}: {postUri: string}) { 26 + const ax = useAnalytics() 26 27 const control = useDialogContext() 27 28 const {currentAccount} = useSession() 28 29 const {data} = useListConvosQuery({status: 'accepted'}) ··· 32 33 33 34 const onSelectChat = (convoId: string) => { 34 35 control.close(() => { 35 - logger.metric('share:press:recentDm', {}, {statsig: true}) 36 + ax.metric('share:press:recentDm', {}) 36 37 navigation.navigate('MessagesConversation', { 37 38 conversation: convoId, 38 39 embed: postUri,
+5 -4
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 9 9 import {type NavigationProp} from '#/lib/routes/types' 10 10 import {shareText, shareUrl} from '#/lib/sharing' 11 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 - import {logger} from '#/logger' 13 12 import {useProfileShadow} from '#/state/cache/profile-shadow' 14 13 import {useSession} from '#/state/session' 15 14 import * as Toast from '#/view/com/util/Toast' ··· 23 22 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 24 23 import * as Menu from '#/components/Menu' 25 24 import {useAgeAssurance} from '#/ageAssurance' 25 + import {useAnalytics} from '#/analytics' 26 26 import {IS_IOS} from '#/env' 27 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 28 import {RecentChats} from './RecentChats' ··· 32 32 post, 33 33 onShare: onShareProp, 34 34 }: ShareMenuItemsProps): React.ReactNode => { 35 + const ax = useAnalytics() 35 36 const {hasSession} = useSession() 36 37 const {_} = useLingui() 37 38 const navigation = useNavigation<NavigationProp>() ··· 54 55 }, [postAuthor]) 55 56 56 57 const onSharePost = () => { 57 - logger.metric('share:press:nativeShare', {}, {statsig: true}) 58 + ax.metric('share:press:nativeShare', {}) 58 59 const url = toShareUrl(href) 59 60 shareUrl(url) 60 61 onShareProp() 61 62 } 62 63 63 64 const onCopyLink = async () => { 64 - logger.metric('share:press:copyLink', {}, {statsig: true}) 65 + ax.metric('share:press:copyLink', {}) 65 66 const url = toShareUrl(href) 66 67 if (IS_IOS) { 67 68 // iOS only ··· 100 101 testID="postDropdownSendViaDMBtn" 101 102 label={_(msg`Send via direct message`)} 102 103 onPress={() => { 103 - logger.metric('share:press:openDmSearch', {}, {statsig: true}) 104 + ax.metric('share:press:openDmSearch', {}) 104 105 sendViaChatControl.open() 105 106 }}> 106 107 <Menu.ItemText>
+6 -5
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 8 8 import {type NavigationProp} from '#/lib/routes/types' 9 9 import {shareText, shareUrl} from '#/lib/sharing' 10 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 - import {logger} from '#/logger' 12 11 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 12 import {useSession} from '#/state/session' 14 13 import {useBreakpoints} from '#/alf' ··· 21 20 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 22 21 import * as Menu from '#/components/Menu' 23 22 import {useAgeAssurance} from '#/ageAssurance' 23 + import {useAnalytics} from '#/analytics' 24 24 import {IS_WEB} from '#/env' 25 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 31 31 timestamp, 32 32 onShare: onShareProp, 33 33 }: ShareMenuItemsProps): React.ReactNode => { 34 + const ax = useAnalytics() 34 35 const {hasSession} = useSession() 35 36 const {gtMobile} = useBreakpoints() 36 37 const {_} = useLingui() ··· 56 57 }, [postAuthor]) 57 58 58 59 const onCopyLink = () => { 59 - logger.metric('share:press:copyLink', {}, {statsig: true}) 60 + ax.metric('share:press:copyLink', {}) 60 61 const url = toShareUrl(href) 61 62 shareUrl(url) 62 63 onShareProp() 63 64 } 64 65 65 66 const onSelectChatToShareTo = (conversation: string) => { 66 - logger.metric('share:press:dmSelected', {}, {statsig: true}) 67 + ax.metric('share:press:dmSelected', {}) 67 68 navigation.navigate('MessagesConversation', { 68 69 conversation, 69 70 embed: postUri, ··· 102 103 testID="postDropdownSendViaDMBtn" 103 104 label={_(msg`Send via direct message`)} 104 105 onPress={() => { 105 - logger.metric('share:press:openDmSearch', {}, {statsig: true}) 106 + ax.metric('share:press:openDmSearch', {}) 106 107 sendViaChatControl.open() 107 108 }}> 108 109 <Menu.ItemText> ··· 117 118 testID="postDropdownEmbedBtn" 118 119 label={_(msg`Embed post`)} 119 120 onPress={() => { 120 - logger.metric('share:press:embed', {}, {statsig: true}) 121 + ax.metric('share:press:embed', {}) 121 122 embedPostControl.open() 122 123 }}> 123 124 <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+11 -13
src/components/PostControls/ShareMenu/index.tsx
··· 13 13 import {makeProfileLink} from '#/lib/routes/links' 14 14 import {shareUrl} from '#/lib/sharing' 15 15 import {toShareUrl} from '#/lib/strings/url-helpers' 16 - import {logger} from '#/logger' 17 16 import {type Shadow} from '#/state/cache/post-shadow' 18 17 import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 18 import {EventStopper} from '#/view/com/util/EventStopper' ··· 21 20 import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 22 21 import {useMenuControl} from '#/components/Menu' 23 22 import * as Menu from '#/components/Menu' 23 + import {useAnalytics} from '#/analytics' 24 24 import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' 25 25 import {ShareMenuItems} from './ShareMenuItems' 26 26 ··· 47 47 hitSlop?: Insets 48 48 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 49 49 }): React.ReactNode => { 50 + const ax = useAnalytics() 50 51 const {_} = useLingui() 51 52 const {feedDescriptor} = useFeedFeedbackContext() 52 53 ··· 61 62 // menuControl.open() fires but RN doesn't expose flushSync. 62 63 setTimeout(menuControl.open) 63 64 64 - logger.metric( 65 - 'post:share', 66 - { 67 - uri: post.uri, 68 - authorDid: post.author.did, 69 - logContext, 70 - feedDescriptor, 71 - postContext: big ? 'thread' : 'feed', 72 - }, 73 - {statsig: true}, 74 - ) 65 + ax.metric('post:share', { 66 + uri: post.uri, 67 + authorDid: post.author.did, 68 + logContext, 69 + feedDescriptor, 70 + postContext: big ? 'thread' : 'feed', 71 + }) 75 72 }, 76 73 }), 77 74 [ 75 + ax, 78 76 menuControl, 79 77 setHasBeenOpen, 80 78 big, ··· 86 84 ) 87 85 88 86 const onNativeLongPress = () => { 89 - logger.metric('share:press:nativeShare', {}, {statsig: true}) 87 + ax.metric('share:press:nativeShare', {}) 90 88 const urip = new AtUri(post.uri) 91 89 const href = makeProfileLink(post.author, 'post', urip.rkey) 92 90 const url = toShareUrl(href)
+4 -3
src/components/PostControls/index.tsx
··· 13 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 14 import {useHaptics} from '#/lib/haptics' 15 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 - import {logger} from '#/logger' 17 16 import {type Shadow} from '#/state/cache/types' 18 17 import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 18 import { ··· 30 29 import {Reply as Bubble} from '#/components/icons/Reply' 31 30 import {useFormatPostStatCount} from '#/components/PostControls/util' 32 31 import * as Skele from '#/components/Skeleton' 32 + import {useAnalytics} from '#/analytics' 33 33 import {BookmarkButton} from './BookmarkButton' 34 34 import { 35 35 PostControlButton, ··· 71 71 viaRepost?: {uri: string; cid: string} 72 72 variant?: 'compact' | 'normal' | 'large' 73 73 }): React.ReactNode => { 74 + const ax = useAnalytics() 74 75 const {_} = useLingui() 75 76 const {openComposer} = useOpenComposer() 76 77 const {feedDescriptor} = useFeedFeedbackContext() ··· 175 176 feedContext, 176 177 reqId, 177 178 }) 178 - logger.metric('post:clickQuotePost', { 179 + ax.metric('post:clickQuotePost', { 179 180 uri: post.uri, 180 181 authorDid: post.author.did, 181 182 logContext, ··· 226 227 !replyDisabled 227 228 ? () => 228 229 requireAuth(() => { 229 - logger.metric('post:clickReply', { 230 + ax.metric('post:clickReply', { 230 231 uri: post.uri, 231 232 authorDid: post.author.did, 232 233 logContext,
+3 -3
src/components/ProfileCard.tsx
··· 16 16 17 17 import {useActorStatus} from '#/lib/actor-status' 18 18 import {getModerationCauseKey} from '#/lib/moderation' 19 - import {type LogEvents} from '#/lib/statsig/statsig' 20 19 import {forceLTR} from '#/lib/strings/bidi' 21 20 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 22 21 import {sanitizeDisplayName} from '#/lib/strings/display-names' ··· 47 46 import {Text} from '#/components/Typography' 48 47 import {useSimpleVerificationState} from '#/components/verification' 49 48 import {VerificationCheck} from '#/components/verification/VerificationCheck' 49 + import {type Metrics} from '#/analytics' 50 50 import type * as bsky from '#/types/bsky' 51 51 52 52 export function Default({ ··· 461 461 export type FollowButtonProps = { 462 462 profile: bsky.profile.AnyProfileView 463 463 moderationOpts: ModerationOpts 464 - logContext: LogEvents['profile:follow']['logContext'] & 465 - LogEvents['profile:unfollow']['logContext'] 464 + logContext: Metrics['profile:follow']['logContext'] & 465 + Metrics['profile:unfollow']['logContext'] 466 466 colorInverted?: boolean 467 467 onFollow?: () => void 468 468 withIcon?: boolean
+11 -14
src/components/ProgressGuide/FollowDialog.tsx
··· 10 10 import {useLingui} from '@lingui/react' 11 11 12 12 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 13 - import {logEvent} from '#/lib/statsig/statsig' 14 - import {logger} from '#/logger' 15 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 14 import {useActorSearch} from '#/state/queries/actor-search' 17 15 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 36 34 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 37 35 import * as ProfileCard from '#/components/ProfileCard' 38 36 import {Text} from '#/components/Typography' 37 + import {useAnalytics} from '#/analytics' 39 38 import {IS_WEB} from '#/env' 40 39 import type * as bsky from '#/types/bsky' 41 40 import {ProgressGuideTask} from './Task' ··· 67 66 guide: Follow10ProgressGuide 68 67 showArrow?: boolean 69 68 }) { 69 + const ax = useAnalytics() 70 70 const {_} = useLingui() 71 71 const control = Dialog.useDialogControl() 72 72 const {gtPhone} = useBreakpoints() ··· 78 78 label={_(msg`Find people to follow`)} 79 79 onPress={() => { 80 80 control.open() 81 - logEvent('progressGuide:followDialog:open', {}) 81 + ax.metric('progressGuide:followDialog:open', {}) 82 82 }} 83 83 size={gtPhone ? 'small' : 'large'} 84 84 color="primary"> ··· 118 118 119 119 function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { 120 120 const {_} = useLingui() 121 + const ax = useAnalytics() 121 122 const interestsDisplayNames = useInterestsDisplayNames() 122 123 const {data: preferences} = usePreferencesQuery() 123 124 const personalizedInterests = preferences?.interests?.tags ··· 271 272 const position = itemsRef.current.findIndex( 272 273 i => i.type === 'profile' && i.profile.did === item.profile.did, 273 274 ) 274 - logger.metric( 275 - 'suggestedUser:seen', 276 - { 277 - logContext: 'ProgressGuide', 278 - recId: undefined, 279 - position: position !== -1 ? position : 0, 280 - suggestedDid: item.profile.did, 281 - category: selectedInterestRef.current, 282 - }, 283 - {statsig: true}, 284 - ) 275 + ax.metric('suggestedUser:seen', { 276 + logContext: 'ProgressGuide', 277 + recId: undefined, 278 + position: position !== -1 ? position : 0, 279 + suggestedDid: item.profile.did, 280 + category: selectedInterestRef.current, 281 + }) 285 282 } 286 283 } 287 284 }
+5 -3
src/components/StarterPack/QrCodeDialog.tsx
··· 19 19 import {Loader} from '#/components/Loader' 20 20 import {QrCode} from '#/components/StarterPack/QrCode' 21 21 import * as Toast from '#/components/Toast' 22 + import {useAnalytics} from '#/analytics' 22 23 import {IS_NATIVE, IS_WEB} from '#/env' 23 24 import * as bsky from '#/types/bsky' 24 25 ··· 32 33 control: DialogControlProps 33 34 }) { 34 35 const {_} = useLingui() 36 + const ax = useAnalytics() 35 37 const {gtMobile} = useBreakpoints() 36 38 const [isSaveProcessing, setIsSaveProcessing] = useState(false) 37 39 const [isCopyProcessing, setIsCopyProcessing] = useState(false) ··· 104 106 link.click() 105 107 } 106 108 107 - logger.metric('starterPack:share', { 109 + ax.metric('starterPack:share', { 108 110 starterPack: starterPack.uri, 109 111 shareType: 'qrcode', 110 112 qrShareType: 'save', ··· 129 131 navigator.clipboard.write([item]) 130 132 }) 131 133 132 - logger.metric('starterPack:share', { 134 + ax.metric('starterPack:share', { 133 135 starterPack: starterPack.uri, 134 136 shareType: 'qrcode', 135 137 qrShareType: 'copy', ··· 145 147 control.close(() => { 146 148 Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( 147 149 () => { 148 - logger.metric('starterPack:share', { 150 + ax.metric('starterPack:share', { 149 151 starterPack: starterPack.uri, 150 152 shareType: 'qrcode', 151 153 qrShareType: 'share',
+3 -2
src/components/StarterPack/ShareDialog.tsx
··· 7 7 import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' 8 8 import {shareUrl} from '#/lib/sharing' 9 9 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 10 - import {logger} from '#/logger' 11 10 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 12 import {type DialogControlProps} from '#/components/Dialog' ··· 17 16 import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' 18 17 import {Loader} from '#/components/Loader' 19 18 import {Text} from '#/components/Typography' 19 + import {useAnalytics} from '#/analytics' 20 20 import {IS_NATIVE, IS_WEB} from '#/env' 21 21 22 22 interface Props { ··· 46 46 control, 47 47 }: Props) { 48 48 const {_} = useLingui() 49 + const ax = useAnalytics() 49 50 const t = useTheme() 50 51 const {gtMobile} = useBreakpoints() 51 52 ··· 54 55 const onShareLink = async () => { 55 56 if (!link) return 56 57 shareUrl(link) 57 - logger.metric('starterPack:share', { 58 + ax.metric('starterPack:share', { 58 59 starterPack: starterPack.uri, 59 60 shareType: 'link', 60 61 })
+4 -3
src/components/StarterPack/Wizard/WizardListCard.tsx
··· 13 13 import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' 14 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' 15 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 - import {logger} from '#/logger' 17 16 import {useSession} from '#/state/session' 18 17 import {UserAvatar} from '#/view/com/util/UserAvatar' 19 18 import { ··· 25 24 import * as Toggle from '#/components/forms/Toggle' 26 25 import {Checkbox} from '#/components/forms/Toggle' 27 26 import {Text} from '#/components/Typography' 27 + import {useAnalytics} from '#/analytics' 28 28 import type * as bsky from '#/types/bsky' 29 29 30 30 function WizardListCard({ ··· 130 130 profile: bsky.profile.AnyProfileView 131 131 moderationOpts: ModerationOpts 132 132 }) { 133 + const ax = useAnalytics() 133 134 const {currentAccount} = useSession() 134 135 135 136 // Determine the "main" profile for this starter pack - either targetDid or current account ··· 151 152 if (profile.did === targetProfileDid) return 152 153 153 154 if (!included) { 154 - logger.metric('starterPack:addUser', {}) 155 + ax.metric('starterPack:addUser', {}) 155 156 dispatch({type: 'AddProfile', profile}) 156 157 } else { 157 - logger.metric('starterPack:removeUser', {}) 158 + ax.metric('starterPack:removeUser', {}) 158 159 dispatch({type: 'RemoveProfile', profileDid: profile.did}) 159 160 } 160 161 }
+8 -6
src/components/WelcomeModal.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 7 8 - import {logger} from '#/logger' 9 8 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 10 9 import {Logo} from '#/view/icons/Logo' 11 10 import {atoms as a, flatten, useBreakpoints, web} from '#/alf' 12 11 import {Button, ButtonText} from '#/components/Button' 13 12 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 13 import {Text} from '#/components/Typography' 14 + import {useAnalytics} from '#/analytics' 15 15 16 16 const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') 17 17 ··· 25 25 26 26 export function WelcomeModal({control}: WelcomeModalProps) { 27 27 const {_} = useLingui() 28 + const ax = useAnalytics() 28 29 const {requestSwitchToAccount} = useLoggedOutViewControls() 29 30 const {gtMobile} = useBreakpoints() 30 31 const [isExiting, setIsExiting] = useState(false) ··· 40 41 41 42 useEffect(() => { 42 43 if (control.isOpen) { 43 - logger.metric('welcomeModal:presented', {}) 44 + ax.metric('welcomeModal:presented', {}) 44 45 } 46 + // eslint-disable-next-line react-hooks/exhaustive-deps 45 47 }, [control.isOpen]) 46 48 47 49 const onPressCreateAccount = () => { 48 - logger.metric('welcomeModal:signupClicked', {}) 50 + ax.metric('welcomeModal:signupClicked', {}) 49 51 control.close() 50 52 requestSwitchToAccount({requestedAccount: 'new'}) 51 53 } 52 54 53 55 const onPressExplore = () => { 54 - logger.metric('welcomeModal:exploreClicked', {}) 56 + ax.metric('welcomeModal:exploreClicked', {}) 55 57 fadeOutAndClose() 56 58 } 57 59 58 60 const onPressSignIn = () => { 59 - logger.metric('welcomeModal:signinClicked', {}) 61 + ax.metric('welcomeModal:signinClicked', {}) 60 62 control.close() 61 63 requestSwitchToAccount({requestedAccount: 'existing'}) 62 64 } ··· 222 224 ]} 223 225 hoverStyle={[a.bg_transparent]} 224 226 onPress={() => { 225 - logger.metric('welcomeModal:dismissed', {}) 227 + ax.metric('welcomeModal:dismissed', {}) 226 228 fadeOutAndClose() 227 229 }} 228 230 color="secondary"
+5 -4
src/components/WhoCanReply.tsx
··· 17 17 18 18 import {HITSLOP_10} from '#/lib/constants' 19 19 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 20 - import {logger} from '#/logger' 21 20 import { 22 21 type ThreadgateAllowUISetting, 23 22 threadgateViewToAllowUISetting, ··· 36 35 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 37 36 import {InlineLinkText} from '#/components/Link' 38 37 import {Text} from '#/components/Typography' 38 + import {useAnalytics} from '#/analytics' 39 39 import {IS_NATIVE} from '#/env' 40 40 import * as bsky from '#/types/bsky' 41 41 ··· 46 46 } 47 47 48 48 export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { 49 + const t = useTheme() 50 + const ax = useAnalytics() 49 51 const {_} = useLingui() 50 - const t = useTheme() 51 52 const infoDialogControl = useDialogControl() 52 53 const editDialogControl = useDialogControl() 53 54 ··· 90 91 Keyboard.dismiss() 91 92 } 92 93 if (isThreadAuthor) { 93 - logger.metric('thread:click:editOwnThreadgate', {}) 94 + ax.metric('thread:click:editOwnThreadgate', {}) 94 95 95 96 // wait on prefetch if it manages to resolve in under 200ms 96 97 // otherwise, proceed immediately and show the spinner -sfn ··· 101 102 editDialogControl.open() 102 103 }) 103 104 } else { 104 - logger.metric('thread:click:viewSomeoneElsesThreadgate', {}) 105 + ax.metric('thread:click:viewSomeoneElsesThreadgate', {}) 105 106 106 107 infoDialogControl.open() 107 108 }
+6 -6
src/components/activity-notifications/SubscribeProfileDialog.tsx
··· 17 17 import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 18 18 import {cleanError} from '#/lib/strings/errors' 19 19 import {sanitizeHandle} from '#/lib/strings/handles' 20 - import {logger} from '#/logger' 21 20 import {updateProfileShadow} from '#/state/cache/profile-shadow' 22 21 import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' 23 22 import {useAgent} from '#/state/session' 24 23 import * as Toast from '#/view/com/util/Toast' 25 - import {platform, useTheme, web} from '#/alf' 26 - import {atoms as a} from '#/alf' 24 + import {atoms as a, platform, useTheme, web} from '#/alf' 27 25 import {Admonition} from '#/components/Admonition' 28 26 import { 29 27 Button, ··· 36 34 import {Loader} from '#/components/Loader' 37 35 import * as ProfileCard from '#/components/ProfileCard' 38 36 import {Text} from '#/components/Typography' 37 + import {useAnalytics} from '#/analytics' 39 38 import {IS_WEB} from '#/env' 40 39 import type * as bsky from '#/types/bsky' 41 40 ··· 71 70 moderationOpts: ModerationOpts 72 71 includeProfile?: boolean 73 72 }) { 73 + const ax = useAnalytics() 74 74 const {_} = useLingui() 75 75 const t = useTheme() 76 76 const agent = useAgent() ··· 133 133 }) 134 134 135 135 if (!activitySubscription.post && !activitySubscription.reply) { 136 - logger.metric('activitySubscription:disable', {}) 136 + ax.metric('activitySubscription:disable', {}) 137 137 Toast.show( 138 138 _( 139 139 msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, ··· 160 160 }, 161 161 ) 162 162 } else { 163 - logger.metric('activitySubscription:enable', { 163 + ax.metric('activitySubscription:enable', { 164 164 setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', 165 165 }) 166 166 if (!initialState.post && !initialState.reply) { ··· 177 177 }) 178 178 }, 179 179 onError: err => { 180 - logger.error('Could not save activity subscription', {message: err}) 180 + ax.logger.error('Could not save activity subscription', {message: err}) 181 181 }, 182 182 }) 183 183
+5 -3
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 20 20 import {createStaticClick, InlineLinkText} from '#/components/Link' 21 21 import * as Toast from '#/components/Toast' 22 22 import {Text} from '#/components/Typography' 23 - import {logger, useAgeAssurance} from '#/ageAssurance' 23 + import {useAgeAssurance} from '#/ageAssurance' 24 24 import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 25 + import {useAnalytics} from '#/analytics' 25 26 import {IS_NATIVE} from '#/env' 26 27 import {useDeviceGeolocationApi} from '#/geolocation' 27 28 ··· 41 42 function Inner({style}: ViewStyleProp & {}) { 42 43 const t = useTheme() 43 44 const {_, i18n} = useLingui() 45 + const ax = useAnalytics() 44 46 const control = useDialogControl() 45 47 const appealControl = Dialog.useDialogControl() 46 48 const locationControl = Dialog.useDialogControl() ··· 138 140 label={_(msg`Contact our moderation team`)} 139 141 {...createStaticClick(() => { 140 142 appealControl.open() 141 - logger.metric('ageAssurance:appealDialogOpen', {}) 143 + ax.metric('ageAssurance:appealDialogOpen', {}) 142 144 })}> 143 145 contact our moderation team 144 146 </InlineLinkText>{' '} ··· 167 169 color={hasInitiated ? 'secondary' : 'primary'} 168 170 onPress={() => { 169 171 control.open() 170 - logger.metric('ageAssurance:initDialogOpen', { 172 + ax.metric('ageAssurance:initDialogOpen', { 171 173 hasInitiatedPreviously: hasInitiated, 172 174 }) 173 175 }}>
+3 -2
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 10 10 import {InlineLinkText} from '#/components/Link' 11 11 import {Text} from '#/components/Typography' 12 12 import {useAgeAssurance} from '#/ageAssurance' 13 - import {logger} from '#/ageAssurance' 13 + import {useAnalytics} from '#/analytics' 14 14 15 15 export function AgeAssuranceAdmonition({ 16 16 children, ··· 40 40 }) { 41 41 const t = useTheme() 42 42 const {_} = useLingui() 43 + const ax = useAnalytics() 43 44 44 45 return ( 45 46 <> ··· 92 93 to={'/settings/account'} 93 94 style={[a.text_sm, a.leading_snug, a.font_semi_bold]} 94 95 onPress={() => { 95 - logger.metric('ageAssurance:navigateToSettings', {}) 96 + ax.metric('ageAssurance:navigateToSettings', {}) 96 97 }}> 97 98 account settings. 98 99 </InlineLinkText>
+3 -1
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 15 15 import {Loader} from '#/components/Loader' 16 16 import {Text} from '#/components/Typography' 17 17 import {logger} from '#/ageAssurance' 18 + import {useAnalytics} from '#/analytics' 18 19 19 20 export function AgeAssuranceAppealDialog({ 20 21 control, ··· 37 38 38 39 function Inner({control}: {control: Dialog.DialogControlProps}) { 39 40 const {_} = useLingui() 41 + const ax = useAnalytics() 40 42 const {currentAccount} = useSession() 41 43 const {gtPhone} = useBreakpoints() 42 44 const agent = useAgent() ··· 46 48 47 49 const {mutate, isPending} = useMutation({ 48 50 mutationFn: async () => { 49 - logger.metric('ageAssurance:appealDialogSubmit', {}) 51 + ax.metric('ageAssurance:appealDialogSubmit', {}) 50 52 51 53 await agent.createModerationReport( 52 54 {
+4 -3
src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
··· 12 12 import {Link} from '#/components/Link' 13 13 import {Text} from '#/components/Typography' 14 14 import {useAgeAssurance} from '#/ageAssurance' 15 - import {logger} from '#/ageAssurance' 15 + import {useAnalytics} from '#/analytics' 16 16 17 17 export function useInternalState() { 18 18 const aa = useAgeAssurance() ··· 42 42 43 43 export function AgeAssuranceDismissibleFeedBanner() { 44 44 const t = useTheme() 45 + const ax = useAnalytics() 45 46 const {_} = useLingui() 46 47 const {visible, close} = useInternalState() 47 48 const copy = useAgeAssuranceCopy() ··· 66 67 to="/settings/account" 67 68 onPress={() => { 68 69 close() 69 - logger.metric('ageAssurance:navigateToSettings', {}) 70 + ax.metric('ageAssurance:navigateToSettings', {}) 70 71 }} 71 72 style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> 72 73 <View ··· 105 106 size="small" 106 107 onPress={() => { 107 108 close() 108 - logger.metric('ageAssurance:dismissFeedBanner', {}) 109 + ax.metric('ageAssurance:dismissFeedBanner', {}) 109 110 }} 110 111 style={[ 111 112 a.absolute,
+3 -2
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 10 10 import {Button, ButtonIcon} from '#/components/Button' 11 11 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 12 12 import {useAgeAssurance} from '#/ageAssurance' 13 - import {logger} from '#/ageAssurance' 13 + import {useAnalytics} from '#/analytics' 14 14 15 15 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 16 16 const {_} = useLingui() 17 + const ax = useAnalytics() 17 18 const aa = useAgeAssurance() 18 19 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 19 20 const copy = useAgeAssuranceCopy() ··· 45 46 completed: true, 46 47 data: undefined, 47 48 }) 48 - logger.metric('ageAssurance:dismissSettingsNotice', {}) 49 + ax.metric('ageAssurance:dismissSettingsNotice', {}) 49 50 }} 50 51 style={[ 51 52 a.absolute,
+8 -8
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 19 19 import {atoms as a, web} from '#/alf' 20 20 import {Admonition} from '#/components/Admonition' 21 21 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 22 - import {urls} from '#/components/ageAssurance/const' 23 - import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const' 22 + import {KWS_SUPPORTED_LANGS, urls} from '#/components/ageAssurance/const' 24 23 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 25 24 import * as Dialog from '#/components/Dialog' 26 25 import {Divider} from '#/components/Divider' ··· 30 29 import {SimpleInlineLinkText} from '#/components/Link' 31 30 import {Loader} from '#/components/Loader' 32 31 import {Text} from '#/components/Typography' 33 - import {logger} from '#/ageAssurance' 34 32 import {useAgeAssurance} from '#/ageAssurance' 35 33 import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' 34 + import {useAnalytics} from '#/analytics' 36 35 37 36 export {useDialogControl} from '#/components/Dialog/context' 38 37 ··· 64 63 65 64 function Inner() { 66 65 const {_} = useLingui() 66 + const ax = useAnalytics() 67 67 const {currentAccount} = useSession() 68 68 const langPrefs = useLanguagePrefs() 69 69 const cleanError = useCleanError() ··· 116 116 const onSubmit = async () => { 117 117 setLanguageError(false) 118 118 119 - logger.metric('ageAssurance:initDialogSubmit', {}) 119 + ax.metric('ageAssurance:initDialogSubmit', {}) 120 120 121 121 try { 122 122 const {status} = runEmailValidation() ··· 143 143 error = _( 144 144 msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, 145 145 ) 146 - logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) 146 + ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) 147 147 } else if (e.error === 'DidTooLong') { 148 148 error = ( 149 149 <> ··· 159 159 </Trans> 160 160 </> 161 161 ) 162 - logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) 162 + ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) 163 163 } else { 164 - logger.metric('ageAssurance:initDialogError', {code: 'other'}) 164 + ax.metric('ageAssurance:initDialogError', {code: 'other'}) 165 165 } 166 166 } else { 167 167 const {clean, raw} = cleanError(e) 168 168 error = clean || raw || error 169 - logger.metric('ageAssurance:initDialogError', {code: 'other'}) 169 + ax.metric('ageAssurance:initDialogError', {code: 'other'}) 170 170 } 171 171 172 172 setError(error)
+6 -5
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 16 16 import {Loader} from '#/components/Loader' 17 17 import {Text} from '#/components/Typography' 18 18 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 19 - import {logger} from '#/ageAssurance' 19 + import {useAnalytics} from '#/analytics' 20 20 import {IS_NATIVE} from '#/env' 21 21 22 22 export type AgeAssuranceRedirectDialogState = { ··· 81 81 82 82 export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { 83 83 const t = useTheme() 84 + const ax = useAnalytics() 84 85 const {_} = useLingui() 85 86 const agent = useAgent() 86 87 const polling = useRef(false) ··· 94 95 95 96 polling.current = true 96 97 97 - logger.metric('ageAssurance:redirectDialogOpen', {}) 98 + ax.metric('ageAssurance:redirectDialogOpen', {}) 98 99 99 100 wait( 100 101 3e3, ··· 125 126 126 127 setSuccess(true) 127 128 128 - logger.metric('ageAssurance:redirectDialogSuccess', {}) 129 + ax.metric('ageAssurance:redirectDialogSuccess', {}) 129 130 }) 130 131 .catch(() => { 131 132 if (unmounted.current) return 132 133 setError(true) 133 - logger.metric('ageAssurance:redirectDialogFail', {}) 134 + ax.metric('ageAssurance:redirectDialogFail', {}) 134 135 }) 135 136 136 137 return () => { 137 138 unmounted.current = true 138 139 } 139 - }, [agent, control]) 140 + }, [ax, agent, control]) 140 141 141 142 if (success) { 142 143 return (
+3 -2
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 13 13 import {Link} from '#/components/Link' 14 14 import {Text} from '#/components/Typography' 15 15 import {useAgeAssurance} from '#/ageAssurance' 16 - import {logger} from '#/ageAssurance' 16 + import {useAnalytics} from '#/analytics' 17 17 18 18 export function AgeRestrictedScreen({ 19 19 children, ··· 27 27 rightHeaderSlot?: React.ReactNode 28 28 }) { 29 29 const {_} = useLingui() 30 + const ax = useAnalytics() 30 31 const copy = useAgeAssuranceCopy() 31 32 const aa = useAgeAssurance() 32 33 ··· 74 75 variant="solid" 75 76 color="primary" 76 77 onPress={() => { 77 - logger.metric('ageAssurance:navigateToSettings', {}) 78 + ax.metric('ageAssurance:navigateToSettings', {}) 78 79 }}> 79 80 <ButtonText> 80 81 <Trans>Go to account settings</Trans>
+5 -3
src/components/contacts/FindContactsBannerNUX.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {HITSLOP_10} from '#/lib/constants' 9 - import {logger} from '#/logger' 10 9 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 11 10 import {atoms as a, useTheme} from '#/alf' 12 11 import {Button} from '#/components/Button' 13 12 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 13 import {Text} from '#/components/Typography' 14 + import {useAnalytics} from '#/analytics' 15 15 import {IS_WEB} from '#/env' 16 16 import {Link} from '../Link' 17 17 import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist' ··· 19 19 export function FindContactsBannerNUX() { 20 20 const t = useTheme() 21 21 const {_} = useLingui() 22 + const ax = useAnalytics() 22 23 const {visible, close} = useInternalState() 23 24 24 25 if (!visible) return null ··· 30 31 to={{screen: 'FindContactsFlow'}} 31 32 label={_(msg`Import contacts to find your friends`)} 32 33 onPress={() => { 33 - logger.metric('contacts:nux:bannerPressed', {}) 34 + ax.metric('contacts:nux:bannerPressed', {}) 34 35 }} 35 36 style={[ 36 37 a.w_full, ··· 84 85 ) 85 86 } 86 87 function useInternalState() { 88 + const ax = useAnalytics() 87 89 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 88 90 const {mutate: save, variables} = useSaveNux() 89 91 const hidden = !!variables ··· 103 105 completed: true, 104 106 data: undefined, 105 107 }) 106 - logger.metric('contacts:nux:bannerDismissed', {}) 108 + ax.metric('contacts:nux:bannerDismissed', {}) 107 109 } 108 110 109 111 return {visible, close}
+7 -5
src/components/contacts/screens/GetContacts.tsx
··· 28 28 import {Loader} from '#/components/Loader' 29 29 import * as Toast from '#/components/Toast' 30 30 import {Text} from '#/components/Typography' 31 + import {useAnalytics} from '#/analytics' 31 32 import { 32 33 contactsWithPhoneNumbersOnly, 33 34 filterMatchedNumbers, ··· 51 52 context: 'Onboarding' | 'Standalone' 52 53 }) { 53 54 const {_} = useLingui() 55 + const ax = useAnalytics() 54 56 const agent = useAgent() 55 57 const insets = useSafeAreaInsets() 56 58 const gutters = useGutters([0, 'wide']) ··· 100 102 }, 101 103 onSuccess: (result, contacts) => { 102 104 if (context === 'Onboarding') { 103 - logger.metric('onboarding:contacts:contactsShared', {}) 105 + ax.metric('onboarding:contacts:contactsShared', {}) 104 106 } 105 107 if (result.matches.length > 0) { 106 - logger.metric('contacts:import:success', { 108 + ax.metric('contacts:import:success', { 107 109 contactCount: contacts.length, 108 110 matchCount: result.matches.length, 109 111 entryPoint: context, 110 112 }) 111 113 } else { 112 - logger.metric('contacts:import:failure', { 114 + ax.metric('contacts:import:failure', { 113 115 reason: 'noValidNumbers', 114 116 entryPoint: context, 115 117 }) ··· 134 136 }) 135 137 }, 136 138 onError: err => { 137 - logger.metric('contacts:import:failure', { 139 + ax.metric('contacts:import:failure', { 138 140 reason: isNetworkError(err) ? 'networkError' : 'unknown', 139 141 entryPoint: context, 140 142 }) ··· 180 182 permissions = await Contacts.requestPermissionsAsync() 181 183 } 182 184 183 - logger.metric('contacts:permission:request', { 185 + ax.metric('contacts:permission:request', { 184 186 status: permissions.granted ? 'granted' : 'denied', 185 187 accessLevelIOS: ios(permissions.accessPrivileges), 186 188 })
+3 -1
src/components/contacts/screens/PhoneInput.tsx
··· 31 31 import {InlineLinkText} from '#/components/Link' 32 32 import {Loader} from '#/components/Loader' 33 33 import {Text} from '#/components/Typography' 34 + import {useAnalytics} from '#/analytics' 34 35 import {useGeolocation} from '#/geolocation' 35 36 import {isFindContactsFeatureEnabled} from '../country-allowlist' 36 37 import { ··· 52 53 onSkip: () => void 53 54 }) { 54 55 const {_} = useLingui() 56 + const ax = useAnalytics() 55 57 const t = useTheme() 56 58 const agent = useAgent() 57 59 const location = useGeolocation() ··· 85 87 payload: {phoneCountryCode, phoneNumber}, 86 88 }) 87 89 88 - logger.metric('contacts:phone:phoneEntered', {entryPoint: context}) 90 + ax.metric('contacts:phone:phoneEntered', {entryPoint: context}) 89 91 }, 90 92 onMutate: () => { 91 93 Keyboard.dismiss()
+3 -1
src/components/contacts/screens/VerifyNumber.tsx
··· 23 23 import {Loader} from '#/components/Loader' 24 24 import * as Toast from '#/components/Toast' 25 25 import {Text} from '#/components/Typography' 26 + import {useAnalytics} from '#/analytics' 26 27 import {OTPInput} from '../components/OTPInput' 27 28 import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' 28 29 import {type Action, type State, useOnPressBackButton} from '../state' ··· 40 41 }) { 41 42 const t = useTheme() 42 43 const {_} = useLingui() 44 + const ax = useAnalytics() 43 45 const agent = useAgent() 44 46 const gutters = useGutters([0, 'wide']) 45 47 ··· 83 85 }) 84 86 }, 1000) 85 87 86 - logger.metric('contacts:phone:phoneVerified', {entryPoint: context}) 88 + ax.metric('contacts:phone:phoneVerified', {entryPoint: context}) 87 89 }, 88 90 onMutate: () => setError(null), 89 91 onError: err => {
+9 -6
src/components/contacts/screens/ViewMatches.tsx
··· 39 39 import * as ProfileCard from '#/components/ProfileCard' 40 40 import * as Toast from '#/components/Toast' 41 41 import {Text} from '#/components/Typography' 42 + import {useAnalytics} from '#/analytics' 42 43 import type * as bsky from '#/types/bsky' 43 44 import {InviteInfo} from '../components/InviteInfo' 44 45 import {type Action, type Contact, type Match, type State} from '../state' ··· 83 84 }) { 84 85 const t = useTheme() 85 86 const {_} = useLingui() 87 + const ax = useAnalytics() 86 88 const gutter = useGutters([0, 'wide']) 87 89 const moderationOpts = useModerationOpts() 88 90 const queryClient = useQueryClient() ··· 109 111 110 112 const cumulativeFollowCount = useRef(0) 111 113 const onFollow = useCallback(() => { 112 - logger.metric('contacts:matches:follow', {entryPoint: context}) 114 + ax.metric('contacts:matches:follow', {entryPoint: context}) 113 115 cumulativeFollowCount.current += 1 114 - }, [context]) 116 + }, [ax, context]) 115 117 116 118 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 117 119 mutationFn: async () => { ··· 132 134 return followableDids 133 135 }, 134 136 onMutate: () => 135 - logger.metric('contacts:matches:followAll', { 137 + ax.metric('contacts:matches:followAll', { 136 138 followCount: followableDids.length, 137 139 entryPoint: context, 138 140 }), ··· 218 220 await agent.app.bsky.contact.dismissMatch({subject: did}) 219 221 }, 220 222 onMutate: did => { 221 - logger.metric('contacts:matches:dismiss', {entryPoint: context}) 223 + ax.metric('contacts:matches:dismiss', {entryPoint: context}) 222 224 dispatch({type: 'DISMISS_MATCH', payload: {did}}) 223 225 }, 224 226 onSuccess: (_res, did) => { ··· 392 394 label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} 393 395 onPress={() => { 394 396 if (context === 'Onboarding') { 395 - logger.metric('onboarding:contacts:nextPressed', { 397 + ax.metric('onboarding:contacts:nextPressed', { 396 398 matchCount: allMatches.length, 397 399 followCount: cumulativeFollowCount.current, 398 400 dismissedMatchCount: state.dismissedMatches.length, ··· 516 518 const gutter = useGutters([0, 'wide']) 517 519 const t = useTheme() 518 520 const {_} = useLingui() 521 + const ax = useAnalytics() 519 522 const {currentAccount} = useSession() 520 523 521 524 const name = contact.name ?? contact.firstName ?? contact.lastName ··· 564 567 color="secondary" 565 568 size="small" 566 569 onPress={async () => { 567 - logger.metric('contacts:matches:invite', { 570 + ax.metric('contacts:matches:invite', { 568 571 entryPoint: context, 569 572 }) 570 573 try {
+4 -3
src/components/dialogs/GifSelect.tsx
··· 11 11 import {msg, Trans} from '@lingui/macro' 12 12 import {useLingui} from '@lingui/react' 13 13 14 - import {logEvent} from '#/lib/statsig/statsig' 15 14 import {cleanError} from '#/lib/strings/errors' 16 15 import { 17 16 type Gif, ··· 30 29 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 31 30 import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 32 31 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 32 + import {useAnalytics} from '#/analytics' 33 33 import {IS_WEB} from '#/env' 34 34 35 35 export function GifSelectDialog({ ··· 280 280 gif: Gif 281 281 onSelectGif: (gif: Gif) => void 282 282 }) { 283 + const ax = useAnalytics() 283 284 const {gtTablet} = useBreakpoints() 284 285 const {_} = useLingui() 285 286 const t = useTheme() 286 287 287 288 const onPress = useCallback(() => { 288 - logEvent('composer:gif:select', {}) 289 + ax.metric('composer:gif:select', {}) 289 290 onSelectGif(gif) 290 - }, [onSelectGif, gif]) 291 + }, [ax, onSelectGif, gif]) 291 292 292 293 return ( 293 294 <Button
+9 -5
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 11 11 12 12 import {useHaptics} from '#/lib/haptics' 13 13 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 14 - import {logger} from '#/logger' 15 14 import {STALE} from '#/state/queries' 16 15 import {useMyListsQuery} from '#/state/queries/my-lists' 17 16 import {useGetPost} from '#/state/queries/post' ··· 51 50 import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 52 51 import {Loader} from '#/components/Loader' 53 52 import {Text} from '#/components/Typography' 53 + import {useAnalytics} from '#/analytics' 54 54 import {IS_IOS} from '#/env' 55 55 56 56 export type PostInteractionSettingsFormProps = { ··· 80 80 }: PostInteractionSettingsFormProps & { 81 81 control: Dialog.DialogControlProps 82 82 }) { 83 + const ax = useAnalytics() 83 84 const onClose = useNonReactiveCallback(() => { 84 - logger.metric('composer:threadgate:save', { 85 + ax.metric('composer:threadgate:save', { 85 86 hasChanged: !!rest.isDirty, 86 87 persist: !!rest.persist, 87 88 replyOptions: ··· 161 162 export function PostInteractionSettingsDialogControlledInner( 162 163 props: PostInteractionSettingsDialogProps, 163 164 ) { 165 + const ax = useAnalytics() 164 166 const {_} = useLingui() 165 167 const {currentAccount} = useSession() 166 168 const [isSaving, setIsSaving] = useState(false) ··· 229 231 230 232 props.control.close() 231 233 } catch (e: any) { 232 - logger.error(`Failed to save post interaction settings`, { 234 + ax.logger.error(`Failed to save post interaction settings`, { 233 235 source: 'PostInteractionSettingsDialogControlledInner', 234 236 safeMessage: e.message, 235 237 }) ··· 244 246 } 245 247 }, [ 246 248 _, 249 + ax, 247 250 props.postUri, 248 251 props.rootPostUri, 249 252 props.control, ··· 689 692 postUri: string 690 693 rootPostUri: string 691 694 }) { 695 + const ax = useAnalytics() 692 696 const queryClient = useQueryClient() 693 697 const agent = useAgent() 694 698 const getPost = useGetPost() ··· 712 716 }), 713 717 ]) 714 718 } catch (e: any) { 715 - logger.error(`Failed to prefetch post interaction settings`, { 719 + ax.logger.error(`Failed to prefetch post interaction settings`, { 716 720 safeMessage: e.message, 717 721 }) 718 722 } 719 - }, [queryClient, agent, postUri, rootPostUri, getPost]) 723 + }, [ax, queryClient, agent, postUri, rootPostUri, getPost]) 720 724 }
+5 -5
src/components/dialogs/ServerInput.tsx
··· 1 1 import {useCallback, useImperativeHandle, useRef, useState} from 'react' 2 - import {View} from 'react-native' 3 - import {useWindowDimensions} from 'react-native' 2 + import {useWindowDimensions, View} from 'react-native' 4 3 import {msg, Trans} from '@lingui/macro' 5 4 import {useLingui} from '@lingui/react' 6 5 7 6 import {BSKY_SERVICE} from '#/lib/constants' 8 - import {logger} from '#/logger' 9 7 import * as persisted from '#/state/persisted' 10 8 import {useSession} from '#/state/session' 11 9 import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' ··· 17 15 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 16 import {InlineLinkText} from '#/components/Link' 19 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 20 19 21 20 type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' 22 21 ··· 27 26 control: Dialog.DialogOuterProps['control'] 28 27 onSelect: (url: string) => void 29 28 }) { 29 + const ax = useAnalytics() 30 30 const {height} = useWindowDimensions() 31 31 const formRef = useRef<DialogInnerRef>(null) 32 32 ··· 43 43 setPreviousCustomAddress(result) 44 44 } 45 45 } 46 - logger.metric('signin:hostingProviderPressed', { 46 + ax.metric('signin:hostingProviderPressed', { 47 47 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 48 48 }) 49 - }, [onSelect, fixedOption]) 49 + }, [ax, onSelect, fixedOption]) 50 50 51 51 return ( 52 52 <Dialog.Outer
+5 -4
src/components/dialogs/StarterPackDialog.tsx
··· 11 11 12 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 13 import {type NavigationProp} from '#/lib/routes/types' 14 - import {logger} from '#/logger' 15 14 import { 16 15 invalidateActorStarterPacksWithMembershipQuery, 17 16 useActorStarterPacksWithMembershipsQuery, ··· 31 30 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 32 31 import {Loader} from '#/components/Loader' 33 32 import {Text} from '#/components/Typography' 33 + import {useAnalytics} from '#/analytics' 34 34 import {IS_WEB} from '#/env' 35 35 import * as bsky from '#/types/bsky' 36 36 ··· 244 244 starterPackWithMembership: StarterPackWithMembership 245 245 targetDid: string 246 246 }) { 247 + const t = useTheme() 248 + const ax = useAnalytics() 247 249 const {_} = useLingui() 248 - const t = useTheme() 249 250 const queryClient = useQueryClient() 250 251 251 252 const starterPack = starterPackWithMembership.starterPack ··· 304 305 listUri: listUri, 305 306 actorDid: targetDid, 306 307 }) 307 - logger.metric('starterPack:addUser', {starterPack: starterPackUri}) 308 + ax.metric('starterPack:addUser', {starterPack: starterPackUri}) 308 309 } else { 309 310 if (!starterPackWithMembership.listItem?.uri) { 310 311 console.error('Cannot remove: missing membership URI') ··· 316 317 actorDid: targetDid, 317 318 membershipUri: starterPackWithMembership.listItem.uri, 318 319 }) 319 - logger.metric('starterPack:removeUser', {starterPack: starterPackUri}) 320 + ax.metric('starterPack:removeUser', {starterPack: starterPackUri}) 320 321 } 321 322 } 322 323
+4 -4
src/components/dialogs/nuxs/FindContactsAnnouncement.tsx
··· 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {logger} from '#/logger' 9 8 import {atoms as a, useTheme, web} from '#/alf' 10 9 import {Button, ButtonText} from '#/components/Button' 11 10 import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' ··· 16 15 isExistingUserAsOf, 17 16 } from '#/components/dialogs/nuxs/utils' 18 17 import {Text} from '#/components/Typography' 19 - import {IS_NATIVE, IS_WEB} from '#/env' 20 - import {IS_E2E} from '#/env' 18 + import {useAnalytics} from '#/analytics' 19 + import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env' 21 20 import {navigate} from '#/Navigation' 22 21 23 22 export const enabled = createIsEnabledCheck(props => { ··· 35 34 export function FindContactsAnnouncement() { 36 35 const t = useTheme() 37 36 const {_} = useLingui() 37 + const ax = useAnalytics() 38 38 const nuxDialogs = useNuxDialogContext() 39 39 const control = Dialog.useDialogControl() 40 40 ··· 115 115 size="large" 116 116 color="primary" 117 117 onPress={() => { 118 - logger.metric('contacts:nux:ctaPressed', {}) 118 + ax.metric('contacts:nux:ctaPressed', {}) 119 119 control.close(() => { 120 120 navigate('FindContactsFlow') 121 121 })
+5 -8
src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {urls} from '#/lib/constants' 8 - import {logger} from '#/logger' 9 8 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 10 9 import {Button, ButtonText} from '#/components/Button' 11 10 import * as Dialog from '#/components/Dialog' ··· 14 13 import {VerifierCheck} from '#/components/icons/VerifierCheck' 15 14 import {Link} from '#/components/Link' 16 15 import {Span, Text} from '#/components/Typography' 16 + import {useAnalytics} from '#/analytics' 17 17 import {IS_NATIVE} from '#/env' 18 18 19 19 export function InitialVerificationAnnouncement() { 20 20 const t = useTheme() 21 21 const {_} = useLingui() 22 + const ax = useAnalytics() 22 23 const {gtMobile} = useBreakpoints() 23 24 const nuxDialogs = useNuxDialogContext() 24 25 const control = Dialog.useDialogControl() ··· 161 162 color="primary" 162 163 style={[a.justify_center, a.w_full]} 163 164 onPress={() => { 164 - logger.metric( 165 - 'verification:learn-more', 166 - { 167 - location: 'initialAnnouncementeNux', 168 - }, 169 - {statsig: false}, 170 - ) 165 + ax.metric('verification:learn-more', { 166 + location: 'initialAnnouncementeNux', 167 + }) 171 168 }}> 172 169 <ButtonText> 173 170 <Trans>Read blog post</Trans>
+1 -1
src/components/dialogs/nuxs/LiveNowBetaDialog.tsx
··· 24 24 '2026-01-16T00:00:00.000Z', 25 25 props.currentProfile.createdAt, 26 26 ) && 27 - !props.gate('disable_live_now_beta') 27 + !props.features.enabled(props.features.DisableLiveNowBeta) 28 28 ) 29 29 }) 30 30
+4 -4
src/components/dialogs/nuxs/index.tsx
··· 8 8 } from 'react' 9 9 import {type AppBskyActorDefs} from '@atproto/api' 10 10 11 - import {useGate} from '#/lib/statsig/statsig' 12 11 import {logger} from '#/logger' 13 12 import {STALE} from '#/state/queries' 14 13 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' ··· 25 24 } from '#/components/dialogs/nuxs/LiveNowBetaDialog' 26 25 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 27 26 import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 27 + import {useAnalytics} from '#/analytics' 28 28 import {useGeolocation} from '#/geolocation' 29 29 30 30 type Context = { ··· 88 88 currentProfile: AppBskyActorDefs.ProfileViewDetailed 89 89 preferences: UsePreferencesQueryResponse 90 90 }) { 91 - const gate = useGate() 91 + const ax = useAnalytics() 92 92 const geolocation = useGeolocation() 93 93 const {nuxs} = useNuxs() 94 94 const [snoozed, setSnoozed] = useState(() => { ··· 133 133 if ( 134 134 enabled && 135 135 !enabled({ 136 - gate, 136 + features: ax.features, 137 137 currentAccount, 138 138 currentProfile, 139 139 preferences, ··· 165 165 break 166 166 } 167 167 }, [ 168 + ax.features, 168 169 nuxs, 169 170 snoozed, 170 171 snoozeNuxDialog, 171 172 saveNux, 172 - gate, 173 173 currentAccount, 174 174 currentProfile, 175 175 preferences,
+2 -2
src/components/dialogs/nuxs/utils.ts
··· 1 1 import {type AppBskyActorDefs} from '@atproto/api' 2 2 3 - import {type useGate} from '#/lib/statsig/statsig' 4 3 import {type UsePreferencesQueryResponse} from '#/state/queries/preferences' 5 4 import {type SessionAccount} from '#/state/session' 5 + import {type AnalyticsContextType} from '#/analytics' 6 6 import {type Geolocation} from '#/geolocation' 7 7 8 8 export type EnabledCheckProps = { 9 - gate: ReturnType<typeof useGate> 9 + features: AnalyticsContextType['features'] 10 10 currentAccount: SessionAccount 11 11 currentProfile: AppBskyActorDefs.ProfileViewDetailed 12 12 preferences: UsePreferencesQueryResponse
+8 -11
src/components/dms/MessageContextMenu.tsx
··· 7 7 8 8 import {useTranslate} from '#/lib/hooks/useTranslate' 9 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 - import {logger} from '#/logger' 11 10 import {useConvoActive} from '#/state/messages/convo' 12 11 import {useLanguagePrefs} from '#/state/preferences' 13 12 import {useSession} from '#/state/session' ··· 22 21 import {ReportDialog} from '#/components/moderation/ReportDialog' 23 22 import * as Prompt from '#/components/Prompt' 24 23 import {usePromptControl} from '#/components/Prompt' 24 + import {useAnalytics} from '#/analytics' 25 25 import {IS_NATIVE} from '#/env' 26 26 import {EmojiReactionPicker} from './EmojiReactionPicker' 27 27 import {hasReachedReactionLimit} from './util' ··· 34 34 children: TriggerProps['children'] 35 35 }): React.ReactNode => { 36 36 const {_} = useLingui() 37 + const ax = useAnalytics() 37 38 const {currentAccount} = useSession() 38 39 const convo = useConvoActive() 39 40 const deleteControl = usePromptControl() ··· 60 61 const onPressTranslateMessage = useCallback(() => { 61 62 translate(message.text, langPrefs.primaryLanguage) 62 63 63 - logger.metric( 64 - 'translate', 65 - { 66 - sourceLanguages: [], 67 - targetLanguage: langPrefs.primaryLanguage, 68 - textLength: message.text.length, 69 - }, 70 - {statsig: false}, 71 - ) 72 - }, [langPrefs.primaryLanguage, message.text, translate]) 64 + ax.metric('translate', { 65 + sourceLanguages: [], 66 + targetLanguage: langPrefs.primaryLanguage, 67 + textLength: message.text.length, 68 + }) 69 + }, [ax, langPrefs.primaryLanguage, message.text, translate]) 73 70 74 71 const onDelete = useCallback(() => { 75 72 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+6 -5
src/components/dms/MessageProfileButton.tsx
··· 7 7 8 8 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 9 9 import {type NavigationProp} from '#/lib/routes/types' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 10 import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 12 11 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 13 12 import * as Toast from '#/view/com/util/Toast' ··· 15 14 import {Button, ButtonIcon} from '#/components/Button' 16 15 import {canBeMessaged} from '#/components/dms/util' 17 16 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' 17 + import {useAnalytics} from '#/analytics' 18 18 19 19 export function MessageProfileButton({ 20 20 profile, ··· 23 23 }) { 24 24 const {_} = useLingui() 25 25 const t = useTheme() 26 + const ax = useAnalytics() 26 27 const navigation = useNavigation<NavigationProp>() 27 28 const requireEmailVerification = useRequireEmailVerification() 28 29 29 30 const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 30 31 const {mutate: initiateConvo} = useGetConvoForMembers({ 31 32 onSuccess: ({convo}) => { 32 - logEvent('chat:open', {logContext: 'ProfileHeader'}) 33 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 33 34 navigation.navigate('MessagesConversation', {conversation: convo.id}) 34 35 }, 35 36 onError: () => { ··· 43 44 } 44 45 45 46 if (convoAvailability.convo) { 46 - logEvent('chat:open', {logContext: 'ProfileHeader'}) 47 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 47 48 navigation.navigate('MessagesConversation', { 48 49 conversation: convoAvailability.convo.id, 49 50 }) 50 51 } else { 51 - logEvent('chat:create', {logContext: 'ProfileHeader'}) 52 + ax.metric('chat:create', {logContext: 'ProfileHeader'}) 52 53 initiateConvo([profile.did]) 53 54 } 54 - }, [navigation, profile.did, initiateConvo, convoAvailability]) 55 + }, [ax, navigation, profile.did, initiateConvo, convoAvailability]) 55 56 56 57 const wrappedOnPress = requireEmailVerification(onPress, { 57 58 instructions: [
+4 -3
src/components/dms/dialogs/NewChatDialog.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 6 - import {logEvent} from '#/lib/statsig/statsig' 7 6 import {logger} from '#/logger' 8 7 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 9 8 import {FAB} from '#/view/com/util/fab/FAB' ··· 12 11 import * as Dialog from '#/components/Dialog' 13 12 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 14 13 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 14 + import {useAnalytics} from '#/analytics' 15 15 16 16 export function NewChat({ 17 17 control, ··· 22 22 }) { 23 23 const t = useTheme() 24 24 const {_} = useLingui() 25 + const ax = useAnalytics() 25 26 const requireEmailVerification = useRequireEmailVerification() 26 27 27 28 const {mutate: createChat} = useGetConvoForMembers({ ··· 29 30 onNewChat(data.convo.id) 30 31 31 32 if (!data.convo.lastMessage) { 32 - logEvent('chat:create', {logContext: 'NewChatDialog'}) 33 + ax.metric('chat:create', {logContext: 'NewChatDialog'}) 33 34 } 34 - logEvent('chat:open', {logContext: 'NewChatDialog'}) 35 + ax.metric('chat:open', {logContext: 'NewChatDialog'}) 35 36 }, 36 37 onError: error => { 37 38 logger.error('Failed to create chat', {safeMessage: error})
+4 -3
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 5 import {logger} from '#/logger' 7 6 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 8 7 import * as Toast from '#/view/com/util/Toast' 9 8 import * as Dialog from '#/components/Dialog' 10 9 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 10 + import {useAnalytics} from '#/analytics' 11 11 12 12 export function SendViaChatDialog({ 13 13 control, ··· 32 32 onSelectChat: (chatId: string) => void 33 33 }) { 34 34 const {_} = useLingui() 35 + const ax = useAnalytics() 35 36 const {mutate: createChat} = useGetConvoForMembers({ 36 37 onSuccess: data => { 37 38 onSelectChat(data.convo.id) 38 39 39 40 if (!data.convo.lastMessage) { 40 - logEvent('chat:create', {logContext: 'SendViaChatDialog'}) 41 + ax.metric('chat:create', {logContext: 'SendViaChatDialog'}) 41 42 } 42 - logEvent('chat:open', {logContext: 'SendViaChatDialog'}) 43 + ax.metric('chat:open', {logContext: 'SendViaChatDialog'}) 43 44 }, 44 45 onError: error => { 45 46 logger.error('Failed to share post to chat', {message: error})
+3 -2
src/components/feeds/PostFeedVideoGridRow.tsx
··· 1 1 import {View} from 'react-native' 2 2 import {AppBskyEmbedVideo} from '@atproto/api' 3 3 4 - import {logEvent} from '#/lib/statsig/statsig' 5 4 import {type FeedPostSliceItem} from '#/state/queries/post-feed' 6 5 import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 7 6 import {atoms as a, useGutters} from '#/alf' ··· 10 9 VideoPostCard, 11 10 VideoPostCardPlaceholder, 12 11 } from '#/components/VideoPostCard' 12 + import {useAnalytics} from '#/analytics' 13 13 14 14 export function PostFeedVideoGridRow({ 15 15 items: slices, ··· 18 18 items: FeedPostSliceItem[] 19 19 sourceContext: VideoFeedSourceContext 20 20 }) { 21 + const ax = useAnalytics() 21 22 const gutters = useGutters(['base', 'base', 0, 'base']) 22 23 const posts = slices 23 24 .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) ··· 43 44 sourceContext={sourceContext} 44 45 moderation={post.moderation} 45 46 onInteract={() => { 46 - logEvent('videoCard:click', {context: 'feed'}) 47 + ax.metric('videoCard:click', {context: 'feed'}) 47 48 }} 48 49 /> 49 50 </Grid.Col>
+3 -3
src/components/hooks/useFollowMethods.ts
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {type LogEvents} from '#/lib/statsig/statsig' 6 5 import {logger} from '#/logger' 7 6 import {type Shadow} from '#/state/cache/types' 8 7 import {useProfileFollowMutationQueue} from '#/state/queries/profile' 9 8 import {useRequireAuth} from '#/state/session' 10 9 import * as Toast from '#/view/com/util/Toast' 10 + import {type Metrics} from '#/analytics/metrics' 11 11 import type * as bsky from '#/types/bsky' 12 12 13 13 export function useFollowMethods({ ··· 15 15 logContext, 16 16 }: { 17 17 profile: Shadow<bsky.profile.AnyProfileView> 18 - logContext: LogEvents['profile:follow']['logContext'] & 19 - LogEvents['profile:unfollow']['logContext'] 18 + logContext: Metrics['profile:follow']['logContext'] & 19 + Metrics['profile:unfollow']['logContext'] 20 20 }) { 21 21 const {_} = useLingui() 22 22 const requireAuth = useRequireAuth()
+7 -4
src/components/interstitials/Trending.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 6 import { 8 7 useTrendingSettings, 9 8 useTrendingSettingsApi, ··· 19 18 import * as Prompt from '#/components/Prompt' 20 19 import {TrendingTopicLink} from '#/components/TrendingTopics' 21 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 22 23 23 export function TrendingInterstitial() { 24 24 const {enabled} = useTrendingConfig() ··· 29 29 export function Inner() { 30 30 const t = useTheme() 31 31 const {_} = useLingui() 32 + const ax = useAnalytics() 32 33 const gutters = useGutters([0, 'base', 0, 'base']) 33 34 const trendingPrompt = Prompt.usePromptControl() 34 35 const {setTrendingDisabled} = useTrendingSettingsApi() ··· 36 37 const noTopics = !isLoading && !error && !trending?.topics?.length 37 38 38 39 const onConfirmHide = React.useCallback(() => { 39 - logEvent('trendingTopics:hide', {context: 'interstitial'}) 40 + ax.metric('trendingTopics:hide', {context: 'interstitial'}) 40 41 setTrendingDisabled(true) 41 - }, [setTrendingDisabled]) 42 + }, [ax, setTrendingDisabled]) 42 43 43 44 return error || noTopics ? null : ( 44 45 <View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}> ··· 94 95 key={topic.link} 95 96 topic={topic} 96 97 onPress={() => { 97 - logEvent('trendingTopic:click', {context: 'interstitial'}) 98 + ax.metric('trendingTopic:click', { 99 + context: 'interstitial', 100 + }) 98 101 }}> 99 102 <View style={[a.py_lg]}> 100 103 <Text
+7 -6
src/components/interstitials/TrendingVideos.tsx
··· 7 7 8 8 import {VIDEO_FEED_URI} from '#/lib/constants' 9 9 import {makeCustomFeedLink} from '#/lib/routes/links' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 10 import {useTrendingSettingsApi} from '#/state/preferences/trending' 12 - import {usePostFeedQuery} from '#/state/queries/post-feed' 13 - import {RQKEY} from '#/state/queries/post-feed' 11 + import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 14 12 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 15 13 import {atoms as a, useGutters, useTheme} from '#/alf' 16 14 import {Button, ButtonIcon} from '#/components/Button' ··· 23 21 CompactVideoPostCard, 24 22 CompactVideoPostCardPlaceholder, 25 23 } from '#/components/VideoPostCard' 24 + import {useAnalytics} from '#/analytics' 26 25 27 26 const CARD_WIDTH = 108 28 27 ··· 36 35 export function TrendingVideos() { 37 36 const t = useTheme() 38 37 const {_} = useLingui() 38 + const ax = useAnalytics() 39 39 const gutters = useGutters([0, 'base']) 40 40 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 41 41 ··· 57 57 58 58 const onConfirmHide = useCallback(() => { 59 59 setTrendingVideoDisabled(true) 60 - logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) 61 - }, [setTrendingVideoDisabled]) 60 + ax.metric('trendingVideos:hide', {context: 'interstitial:discover'}) 61 + }, [ax, setTrendingVideoDisabled]) 62 62 63 63 if (error) { 64 64 return null ··· 147 147 }: { 148 148 data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 149 149 }) { 150 + const ax = useAnalytics() 150 151 const items = useMemo(() => { 151 152 return data.pages 152 153 .flatMap(page => page.slices) ··· 169 170 sourceInterstitial: 'discover', 170 171 }} 171 172 onInteract={() => { 172 - logEvent('videoCard:click', { 173 + ax.metric('videoCard:click', { 173 174 context: 'interstitial:discover', 174 175 }) 175 176 }}
+4 -11
src/components/live/LiveStatusDialog.tsx
··· 11 11 import {type NavigationProp} from '#/lib/routes/types' 12 12 import {sanitizeHandle} from '#/lib/strings/handles' 13 13 import {toNiceDomain} from '#/lib/strings/url-helpers' 14 - import {logger} from '#/logger' 15 14 import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 15 import {unstableCacheProfileView} from '#/state/queries/profile' 17 16 import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' ··· 22 21 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 23 22 import * as ProfileCard from '#/components/ProfileCard' 24 23 import {Text} from '#/components/Typography' 24 + import {useAnalytics} from '#/analytics' 25 25 import type * as bsky from '#/types/bsky' 26 26 import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 27 27 import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' ··· 103 103 padding?: 'lg' | 'xl' 104 104 onPressOpenProfile: () => void 105 105 }) { 106 + const ax = useAnalytics() 106 107 const {_} = useLingui() 107 108 const t = useTheme() 108 109 const queryClient = useQueryClient() ··· 174 175 color="primary" 175 176 variant="solid" 176 177 onPress={() => { 177 - logger.metric( 178 - 'live:card:watch', 179 - {subject: profile.did}, 180 - {statsig: true}, 181 - ) 178 + ax.metric('live:card:watch', {subject: profile.did}) 182 179 openLink(embed.external.uri, false) 183 180 }}> 184 181 <ButtonText> ··· 207 204 color="secondary" 208 205 variant="solid" 209 206 onPress={() => { 210 - logger.metric( 211 - 'live:card:openProfile', 212 - {subject: profile.did}, 213 - {statsig: true}, 214 - ) 207 + ax.metric('live:card:openProfile', {subject: profile.did}) 215 208 unstableCacheProfileView(queryClient, profile) 216 209 onPressOpenProfile() 217 210 }}>
+9 -15
src/components/live/queries.ts
··· 12 12 import {uploadBlob} from '#/lib/api' 13 13 import {imageToThumb} from '#/lib/api/resolve' 14 14 import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 15 - import {logger} from '#/logger' 16 15 import {updateProfileShadow} from '#/state/cache/profile-shadow' 17 16 import {useLiveNowConfig} from '#/state/service-config' 18 17 import {useAgent, useSession} from '#/state/session' 19 18 import * as Toast from '#/view/com/util/Toast' 20 19 import {useDialogContext} from '#/components/Dialog' 21 20 import {getLiveServiceNames} from '#/components/live/utils' 21 + import {useAnalytics} from '#/analytics' 22 22 23 23 export function useLiveLinkMetaQuery(url: string | null) { 24 24 const liveNowConfig = useLiveNowConfig() ··· 50 50 linkMeta: LinkMeta | null | undefined, 51 51 createdAt?: string, 52 52 ) { 53 + const ax = useAnalytics() 53 54 const {currentAccount} = useSession() 54 55 const agent = useAgent() 55 56 const queryClient = useQueryClient() ··· 77 78 thumb = blob.data.blob 78 79 } 79 80 } catch (e: any) { 80 - logger.error(`Failed to upload thumbnail for live status`, { 81 + ax.logger.error(`Failed to upload thumbnail for live status`, { 81 82 url: linkMeta.url, 82 83 image: linkMeta.image, 83 84 safeMessage: e, ··· 133 134 } 134 135 }, 135 136 onError: (e: any) => { 136 - logger.error(`Failed to upsert live status`, { 137 + ax.logger.error(`Failed to upsert live status`, { 137 138 url: linkMeta?.url, 138 139 image: linkMeta?.image, 139 140 safeMessage: e, ··· 141 142 }, 142 143 onSuccess: ({record, image}) => { 143 144 if (createdAt) { 144 - logger.metric( 145 - 'live:edit', 146 - {duration: record.durationMinutes}, 147 - {statsig: true}, 148 - ) 145 + ax.metric('live:edit', {duration: record.durationMinutes}) 149 146 } else { 150 - logger.metric( 151 - 'live:create', 152 - {duration: record.durationMinutes}, 153 - {statsig: true}, 154 - ) 147 + ax.metric('live:create', {duration: record.durationMinutes}) 155 148 } 156 149 157 150 Toast.show(_(msg`You are now live!`)) ··· 187 180 } 188 181 189 182 export function useRemoveLiveStatusMutation() { 183 + const ax = useAnalytics() 190 184 const {currentAccount} = useSession() 191 185 const agent = useAgent() 192 186 const queryClient = useQueryClient() ··· 203 197 }) 204 198 }, 205 199 onError: (e: any) => { 206 - logger.error(`Failed to remove live status`, { 200 + ax.logger.error(`Failed to remove live status`, { 207 201 safeMessage: e, 208 202 }) 209 203 }, 210 204 onSuccess: () => { 211 - logger.metric('live:remove', {}, {statsig: true}) 205 + ax.metric('live:remove', {}) 212 206 Toast.show(_(msg`You are no longer live`)) 213 207 control.close(() => { 214 208 if (!currentAccount) return
+18 -24
src/components/moderation/ReportDialog/index.tsx
··· 6 6 7 7 import {wait} from '#/lib/async/wait' 8 8 import {getLabelingServiceTitle} from '#/lib/moderation' 9 + import {useCallOnce} from '#/lib/once' 9 10 import {sanitizeHandle} from '#/lib/strings/handles' 10 - import {Logger} from '#/logger' 11 11 import {useMyLabelersQuery} from '#/state/queries/preferences' 12 12 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 13 13 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 28 28 import {createStaticClick, InlineLinkText, Link} from '#/components/Link' 29 29 import {Loader} from '#/components/Loader' 30 30 import {Text} from '#/components/Typography' 31 + import {useAnalytics} from '#/analytics' 31 32 import {IS_NATIVE} from '#/env' 32 33 import {useSubmitReportMutation} from './action' 33 34 import { ··· 53 54 return useGlobalDialogsControlContext().reportDialogControl 54 55 } 55 56 56 - const logger = Logger.create(Logger.Context.ReportDialog) 57 - 58 57 export function GlobalReportDialog() { 59 58 const {value, control} = useGlobalReportDialogControl() 60 59 return <ReportDialog control={control} subject={value?.subject} /> ··· 65 64 subject?: ReportSubject 66 65 }, 67 66 ) { 67 + const ax = useAnalytics() 68 68 const subject = React.useMemo( 69 69 () => (props.subject ? parseReportSubject(props.subject) : undefined), 70 70 [props.subject], 71 71 ) 72 72 const onClose = React.useCallback(() => { 73 - logger.metric('reportDialog:close', {}, {statsig: false}) 74 - }, []) 73 + ax.metric('reportDialog:close', {}) 74 + }, [ax]) 75 75 return ( 76 76 <Dialog.Outer control={props.control} onClose={onClose}> 77 77 <Dialog.Handle /> ··· 103 103 } 104 104 105 105 function Inner(props: ReportDialogProps) { 106 + const ax = useAnalytics() 107 + const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) 106 108 const t = useTheme() 107 109 const {_} = useLingui() 108 110 const ref = React.useRef<ScrollView>(null) ··· 208 210 }), 209 211 ) 210 212 setSuccess(true) 211 - logger.metric( 212 - 'reportDialog:success', 213 - { 214 - reason: state.selectedOption?.reason ?? '', 215 - labeler: state.selectedLabeler?.creator.handle ?? '', 216 - details: !!state.details, 217 - }, 218 - {statsig: false}, 219 - ) 213 + ax.metric('reportDialog:success', { 214 + reason: state.selectedOption?.reason ?? '', 215 + labeler: state.selectedLabeler?.creator.handle ?? '', 216 + details: !!state.details, 217 + }) 220 218 // give time for user feedback 221 219 setTimeout(() => { 222 220 props.control.close(() => { ··· 224 222 }) 225 223 }, 1e3) 226 224 } catch (e: any) { 227 - logger.metric('reportDialog:failure', {}, {statsig: false}) 225 + ax.metric('reportDialog:failure', {}) 228 226 logger.error(e, { 229 227 source: 'ReportDialog', 230 228 }) ··· 237 235 } 238 236 }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) 239 237 240 - React.useEffect(() => { 241 - logger.metric( 242 - 'reportDialog:open', 243 - { 244 - subjectType: props.subject.type, 245 - }, 246 - {statsig: false}, 247 - ) 248 - }, [props.subject]) 238 + useCallOnce(() => { 239 + ax.metric('reportDialog:open', { 240 + subjectType: props.subject.type, 241 + }) 242 + })() 249 243 250 244 return ( 251 245 <Dialog.ScrollableInner
+3 -2
src/components/verification/VerificationCheckButton.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logger} from '#/logger' 6 5 import {type Shadow} from '#/state/cache/types' 7 6 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 7 import {Button} from '#/components/Button' ··· 12 11 import {VerificationCheck} from '#/components/verification/VerificationCheck' 13 12 import {VerificationsDialog} from '#/components/verification/VerificationsDialog' 14 13 import {VerifierDialog} from '#/components/verification/VerifierDialog' 14 + import {useAnalytics} from '#/analytics' 15 15 import type * as bsky from '#/types/bsky' 16 16 17 17 export function shouldShowVerificationCheckButton( ··· 77 77 size: 'lg' | 'md' | 'sm' 78 78 }) { 79 79 const t = useTheme() 80 + const ax = useAnalytics() 80 81 const {_} = useLingui() 81 82 const verificationsDialogControl = useDialogControl() 82 83 const verifierDialogControl = useDialogControl() ··· 101 102 hitSlop={20} 102 103 onPress={evt => { 103 104 evt.preventDefault() 104 - logger.metric('verification:badge:click', {}, {statsig: true}) 105 + ax.metric('verification:badge:click', {}) 105 106 if (state.profile.role === 'verifier') { 106 107 verifierDialogControl.open() 107 108 } else {
+5 -8
src/components/verification/VerificationsDialog.tsx
··· 5 5 6 6 import {urls} from '#/lib/constants' 7 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 - import {logger} from '#/logger' 9 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 9 import {useProfileQuery} from '#/state/queries/profile' 11 10 import {useSession} from '#/state/session' ··· 20 19 import {Text} from '#/components/Typography' 21 20 import {type FullVerificationState} from '#/components/verification' 22 21 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 22 + import {useAnalytics} from '#/analytics' 23 23 import type * as bsky from '#/types/bsky' 24 24 25 25 export {useDialogControl} from '#/components/Dialog' ··· 55 55 verificationState: FullVerificationState 56 56 }) { 57 57 const t = useTheme() 58 + const ax = useAnalytics() 58 59 const {_} = useLingui() 59 60 const {gtMobile} = useBreakpoints() 60 61 ··· 158 159 color="secondary" 159 160 style={[a.justify_center]} 160 161 onPress={() => { 161 - logger.metric( 162 - 'verification:learn-more', 163 - { 164 - location: 'verificationsDialog', 165 - }, 166 - {statsig: true}, 167 - ) 162 + ax.metric('verification:learn-more', { 163 + location: 'verificationsDialog', 164 + }) 168 165 }}> 169 166 <ButtonText> 170 167 <Trans context="english-only-resource">Learn more</Trans>
+5 -8
src/components/verification/VerifierDialog.tsx
··· 5 5 6 6 import {urls} from '#/lib/constants' 7 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 - import {logger} from '#/logger' 9 8 import {useSession} from '#/state/session' 10 9 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 10 import {Button, ButtonText} from '#/components/Button' ··· 14 13 import {Link} from '#/components/Link' 15 14 import {Text} from '#/components/Typography' 16 15 import {type FullVerificationState} from '#/components/verification' 16 + import {useAnalytics} from '#/analytics' 17 17 import type * as bsky from '#/types/bsky' 18 18 19 19 export {useDialogControl} from '#/components/Dialog' ··· 49 49 verificationState: FullVerificationState 50 50 }) { 51 51 const t = useTheme() 52 + const ax = useAnalytics() 52 53 const {_} = useLingui() 53 54 const {gtMobile} = useBreakpoints() 54 55 const {currentAccount} = useSession() ··· 126 127 color="primary" 127 128 style={[a.justify_center]} 128 129 onPress={() => { 129 - logger.metric( 130 - 'verification:learn-more', 131 - { 132 - location: 'verifierDialog', 133 - }, 134 - {statsig: true}, 135 - ) 130 + ax.metric('verification:learn-more', { 131 + location: 'verifierDialog', 132 + }) 136 133 }}> 137 134 <ButtonText> 138 135 <Trans context="english-only-resource">Learn more</Trans>
+19 -1
src/env/common.ts
··· 50 50 51 51 /** 52 52 * This will always be in the format of YYMMDDHH, so that it always increases 53 - * for each build. This should only be used for StatSig reporting and shouldn't 53 + * for each build. This should only be used for analytics reporting and shouldn't 54 54 * be used to identify a specific bundle. 55 55 */ 56 56 export const BUNDLE_DATE: number = ··· 83 83 */ 84 84 export const CHAT_PROXY_DID: Did = 85 85 process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' 86 + 87 + /** 88 + * Metrics API host 89 + */ 90 + export const METRICS_API_HOST: string = 91 + process.env.EXPO_PUBLIC_METRICS_API_HOST || 'https://events.bsky.app' 92 + 93 + /** 94 + * Growthbook API host 95 + */ 96 + export const GROWTHBOOK_API_HOST: string = 97 + process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb` 98 + 99 + /** 100 + * Growthbook client key 101 + */ 102 + export const GROWTHBOOK_CLIENT_KEY: string = 103 + process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY || 'sdk-7gkUkGy9wguUjyFe' 86 104 87 105 /** 88 106 * Sentry DSN for telemetry
+4 -3
src/features/liveEvents/components/LiveEventFeedCardCompact.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 - import {logger} from '#/logger' 10 9 import {atoms as a, utils} from '#/alf' 11 10 import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 12 11 import {Link} from '#/components/Link' 13 12 import {Text} from '#/components/Typography' 13 + import {useAnalytics} from '#/analytics' 14 14 import { 15 15 type LiveEventFeed, 16 16 type LiveEventFeedMetricContext, ··· 26 26 metricContext: LiveEventFeedMetricContext 27 27 }) { 28 28 const {_} = useLingui() 29 + const ax = useAnalytics() 29 30 30 31 const layout = feed.layouts.compact 31 32 const overlayColor = layout.overlayColor ··· 39 40 }, [feed.url]) 40 41 41 42 useEffect(() => { 42 - logger.metric('liveEvents:feedBanner:seen', { 43 + ax.metric('liveEvents:feedBanner:seen', { 43 44 feed: feed.url, 44 45 context: metricContext, 45 46 }) ··· 52 53 label={_(msg`Live event happening now: ${feed.title}`)} 53 54 style={[a.w_full]} 54 55 onPress={() => { 55 - logger.metric('liveEvents:feedBanner:click', { 56 + ax.metric('liveEvents:feedBanner:click', { 56 57 feed: feed.url, 57 58 context: metricContext, 58 59 })
+8 -7
src/features/liveEvents/components/LiveEventFeedCardWide.tsx
··· 1 - import {useEffect, useMemo} from 'react' 1 + import {useMemo} from 'react' 2 2 import {View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {LinearGradient} from 'expo-linear-gradient' 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 + import {useCallOnce} from '#/lib/once' 8 9 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 - import {logger} from '#/logger' 10 10 import {atoms as a, useBreakpoints, utils} from '#/alf' 11 11 import {Link} from '#/components/Link' 12 12 import {Text} from '#/components/Typography' 13 + import {useAnalytics} from '#/analytics' 13 14 import { 14 15 type LiveEventFeed, 15 16 type LiveEventFeedMetricContext, ··· 24 25 feed: LiveEventFeed 25 26 metricContext: LiveEventFeedMetricContext 26 27 }) { 28 + const ax = useAnalytics() 27 29 const {_} = useLingui() 28 30 const {gtPhone} = useBreakpoints() 29 31 ··· 38 40 return '/' 39 41 }, [feed.url]) 40 42 41 - useEffect(() => { 42 - logger.metric('liveEvents:feedBanner:seen', { 43 + useCallOnce(() => { 44 + ax.metric('liveEvents:feedBanner:seen', { 43 45 feed: feed.url, 44 46 context: metricContext, 45 47 }) 46 - // eslint-disable-next-line react-hooks/exhaustive-deps 47 - }, []) 48 + })() 48 49 49 50 return ( 50 51 <Link ··· 52 53 label={_(msg`Live event happening now: ${feed.title}`)} 53 54 style={[a.w_full]} 54 55 onPress={() => { 55 - logger.metric('liveEvents:feedBanner:click', { 56 + ax.metric('liveEvents:feedBanner:click', { 56 57 feed: feed.url, 57 58 context: metricContext, 58 59 })
+6 -5
src/features/liveEvents/preferences.ts
··· 2 2 import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api' 3 3 import {useMutation, useQueryClient} from '@tanstack/react-query' 4 4 5 - import {logger} from '#/logger' 6 5 import { 7 6 preferencesQueryKey, 8 7 usePreferencesQuery, 9 8 } from '#/state/queries/preferences' 10 9 import {useAgent} from '#/state/session' 10 + import {useAnalytics} from '#/analytics' 11 11 import {IS_WEB} from '#/env' 12 12 import * as env from '#/env' 13 13 import { ··· 63 63 undoAction: LiveEventPreferencesAction | null 64 64 }) => void 65 65 }) { 66 + const ax = useAnalytics() 66 67 const queryClient = useQueryClient() 67 68 const agent = useAgent() 68 69 ··· 116 117 case 'hideFeed': 117 118 case 'unhideFeed': { 118 119 if (!props.feed) { 119 - logger.error( 120 + ax.logger.error( 120 121 `useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`, 121 122 { 122 123 action, ··· 125 126 break 126 127 } 127 128 128 - logger.metric( 129 + ax.metric( 129 130 action.type === 'hideFeed' 130 131 ? 'liveEvents:feedBanner:hide' 131 132 : 'liveEvents:feedBanner:unhide', ··· 138 139 } 139 140 case 'toggleHideAllFeeds': { 140 141 if (prefs!.hideAllFeeds) { 141 - logger.metric('liveEvents:hideAllFeedBanners', { 142 + ax.metric('liveEvents:hideAllFeedBanners', { 142 143 context: props.metricContext, 143 144 }) 144 145 } else { 145 - logger.metric('liveEvents:unhideAllFeedBanners', { 146 + ax.metric('liveEvents:unhideAllFeedBanners', { 146 147 context: props.metricContext, 147 148 }) 148 149 }
+26
src/lib/appState.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import {AppState, type AppStateStatus} from 'react-native' 3 + 4 + export const getCurrentState = () => AppState.currentState 5 + 6 + export function onAppStateChange(cb: (state: AppStateStatus) => void) { 7 + let prev = AppState.currentState 8 + return AppState.addEventListener('change', next => { 9 + if (next === prev) return 10 + prev = next 11 + cb(next) 12 + }) 13 + } 14 + 15 + export function useAppState() { 16 + const [state, setState] = useState(AppState.currentState) 17 + 18 + useEffect(() => { 19 + const sub = onAppStateChange(next => { 20 + setState(next) 21 + }) 22 + return () => sub.remove() 23 + }, []) 24 + 25 + return state 26 + }
+6 -5
src/lib/hooks/useAccountSwitcher.ts
··· 6 6 import {type SessionAccount, useSessionApi} from '#/state/session' 7 7 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 8 8 import * as Toast from '#/view/com/util/Toast' 9 + import {useAnalytics} from '#/analytics' 10 + import {type Metrics} from '#/analytics/metrics' 9 11 import {IS_WEB} from '#/env' 10 - import {logEvent} from '../statsig/statsig' 11 - import {type LogEvents} from '../statsig/statsig' 12 12 13 13 export function useAccountSwitcher() { 14 + const ax = useAnalytics() 14 15 const [pendingDid, setPendingDid] = useState<string | null>(null) 15 16 const {_} = useLingui() 16 17 const {resumeSession} = useSessionApi() ··· 19 20 const onPressSwitchAccount = useCallback( 20 21 async ( 21 22 account: SessionAccount, 22 - logContext: LogEvents['account:loggedIn']['logContext'], 23 + logContext: Metrics['account:loggedIn']['logContext'], 23 24 ) => { 24 25 if (pendingDid) { 25 26 // The session API isn't resilient to race conditions so let's just ignore this. ··· 37 38 history.pushState(null, '', '/') 38 39 } 39 40 await resumeSession(account, true) 40 - logEvent('account:loggedIn', {logContext, withPassword: false}) 41 + ax.metric('account:loggedIn', {logContext, withPassword: false}) 41 42 Toast.show(_(msg`Signed in as @${account.handle}`)) 42 43 } else { 43 44 requestSwitchToAccount({requestedAccount: account.did}) ··· 59 60 setPendingDid(null) 60 61 } 61 62 }, 62 - [_, resumeSession, requestSwitchToAccount, pendingDid], 63 + [_, ax, resumeSession, requestSwitchToAccount, pendingDid], 63 64 ) 64 65 65 66 return {onPressSwitchAccount, pendingDid}
-15
src/lib/hooks/useAppState.ts
··· 1 - import {useEffect, useState} from 'react' 2 - import {AppState} from 'react-native' 3 - 4 - export function useAppState() { 5 - const [state, setState] = useState(AppState.currentState) 6 - 7 - useEffect(() => { 8 - const sub = AppState.addEventListener('change', nextAppState => { 9 - setState(nextAppState) 10 - }) 11 - return () => sub.remove() 12 - }, []) 13 - 14 - return state 15 - }
-20
src/lib/hooks/useCallOnce.ts
··· 1 - import {useCallback} from 'react' 2 - 3 - export enum OnceKey { 4 - PreferencesThread = 'preferences:thread', 5 - } 6 - 7 - const called: Record<OnceKey, boolean> = { 8 - [OnceKey.PreferencesThread]: false, 9 - } 10 - 11 - export function useCallOnce(key: OnceKey) { 12 - return useCallback( 13 - (cb: () => void) => { 14 - if (called[key] === true) return 15 - called[key] = true 16 - cb() 17 - }, 18 - [key], 19 - ) 20 - }
+4 -2
src/lib/hooks/useIntentHandler.ts
··· 5 5 6 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 7 import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 8 - import {logger} from '#/logger' 9 8 import {useSession} from '#/state/session' 10 9 import {useCloseAllActiveElements} from '#/state/util' 11 10 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 11 + import {useAnalytics} from '#/analytics' 12 12 import {IS_IOS, IS_NATIVE} from '#/env' 13 13 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 14 14 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 22 22 23 23 export function useIntentHandler() { 24 24 const incomingUrl = Linking.useLinkingURL() 25 + const ax = useAnalytics() 25 26 const composeIntent = useComposeIntent() 26 27 const verifyEmailIntent = useVerifyEmailIntent() 27 28 const {currentAccount} = useSession() ··· 36 37 37 38 const referrerInfo = Referrer.getReferrerInfo() 38 39 if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 39 - logger.metric('deepLink:referrerReceived', { 40 + ax.metric('deepLink:referrerReceived', { 40 41 to: url, 41 42 referrer: referrerInfo?.referrer, 42 43 hostname: referrerInfo?.hostname, ··· 95 96 } 96 97 }, [ 97 98 incomingUrl, 99 + ax, 98 100 composeIntent, 99 101 verifyEmailIntent, 100 102 currentAccount,
+3 -3
src/lib/hooks/useIsBskyTeam.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 - import {useGate} from '#/lib/statsig/statsig' 3 + import {useAnalytics} from '#/analytics' 4 4 5 5 export function useIsBskyTeam() { 6 - const gate = useGate() 7 - return useMemo(() => gate('is_bsky_team_member'), [gate]) 6 + const ax = useAnalytics() 7 + return useMemo(() => ax.features.enabled(ax.features.IsBskyTeam), [ax]) 8 8 }
+16 -12
src/lib/hooks/useNotificationHandler.ts
··· 16 16 import {useSession} from '#/state/session' 17 17 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 18 18 import {useCloseAllActiveElements} from '#/state/util' 19 + import {useAnalytics} from '#/analytics' 19 20 import {IS_ANDROID, IS_IOS} from '#/env' 20 21 import {resetToTab} from '#/Navigation' 21 22 import {router} from '#/routes' ··· 75 76 let lastHandledNotificationDateDedupe = 0 76 77 77 78 export function useNotificationsHandler() { 79 + const ax = useAnalytics() 80 + const logger = ax.logger.useChild(ax.logger.Context.Notifications) 78 81 const queryClient = useQueryClient() 79 82 const {currentAccount, accounts} = useSession() 80 83 const {onPressSwitchAccount} = useAccountSwitcher() ··· 190 193 if (!payload) return 191 194 192 195 if (payload.reason === 'chat-message') { 193 - notyLogger.debug(`useNotificationsHandler: handling chat message`, { 196 + logger.debug(`useNotificationsHandler: handling chat message`, { 194 197 payload, 195 198 }) 196 199 ··· 250 253 const [screen, params] = router.matchPath(url) 251 254 // @ts-expect-error router is not typed :/ -sfn 252 255 navigation.navigate('HomeTab', {screen, params}) 253 - notyLogger.debug(`useNotificationsHandler: navigate`, { 256 + logger.debug(`useNotificationsHandler: navigate`, { 254 257 screen, 255 258 params, 256 259 }) ··· 264 267 265 268 if (!payload) return DEFAULT_HANDLER_OPTIONS 266 269 267 - notyLogger.debug('useNotificationsHandler: incoming', {e, payload}) 270 + logger.debug('useNotificationsHandler: incoming', {e, payload}) 268 271 269 272 if ( 270 273 payload.reason === 'chat-message' && ··· 290 293 if (e.notification.date === lastHandledNotificationDateDedupe) return 291 294 lastHandledNotificationDateDedupe = e.notification.date 292 295 293 - notyLogger.debug('useNotificationsHandler: response received', { 296 + logger.debug('useNotificationsHandler: response received', { 294 297 actionIdentifier: e.actionIdentifier, 295 298 }) 296 299 ··· 301 304 const payload = getNotificationPayload(e.notification) 302 305 303 306 if (payload) { 304 - notyLogger.debug( 307 + logger.debug( 305 308 'User pressed a notification, opening notifications tab', 306 309 {}, 307 310 ) 308 - notyLogger.metric( 309 - 'notifications:openApp', 310 - {reason: payload.reason, causedBoot: false}, 311 - {statsig: false}, 312 - ) 311 + ax.metric('notifications:openApp', { 312 + reason: payload.reason, 313 + causedBoot: false, 314 + }) 313 315 314 316 invalidateCachedUnreadPage() 315 317 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) ··· 322 324 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) 323 325 } 324 326 325 - notyLogger.debug('Notifications: handleNotification', { 327 + logger.debug('Notifications: handleNotification', { 326 328 content: e.notification.request.content, 327 329 payload: payload, 328 330 }) ··· 330 332 handleNotification(payload) 331 333 Notifications.dismissAllNotificationsAsync() 332 334 } else { 333 - notyLogger.error('useNotificationsHandler: received no payload', { 335 + logger.error('useNotificationsHandler: received no payload', { 334 336 identifier: e.notification.request.identifier, 335 337 }) 336 338 } ··· 350 352 responseReceivedListener.remove() 351 353 } 352 354 }, [ 355 + ax, 356 + logger, 353 357 queryClient, 354 358 currentAccount, 355 359 currentConvoId,
+2 -3
src/lib/hooks/useOTAUpdates.ts
··· 12 12 13 13 import {isNetworkError} from '#/lib/strings/errors' 14 14 import {logger} from '#/logger' 15 - import {IS_ANDROID, IS_IOS} from '#/env' 16 - import {IS_TESTFLIGHT} from '#/env' 15 + import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env' 17 16 18 17 const MINIMUM_MINIMIZE_TIME = 15 * 60e3 19 18 ··· 170 169 return 171 170 } 172 171 173 - // We use this setTimeout to allow Statsig to initialize before we check for an update 172 + // We use this setTimeout to allow analytics to initialize before we check for an update 174 173 // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This 175 174 // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update 176 175 // immediately.
+4 -3
src/lib/hooks/useOpenLink.ts
··· 2 2 import {Linking} from 'react-native' 3 3 import * as WebBrowser from 'expo-web-browser' 4 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 5 import { 7 6 createBskyAppAbsoluteUrl, 8 7 createProxiedUrl, ··· 16 15 import {useTheme} from '#/alf' 17 16 import {useDialogContext} from '#/components/Dialog' 18 17 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 18 + import {useAnalytics} from '#/analytics' 19 19 import {IS_NATIVE} from '#/env' 20 20 21 21 export function useOpenLink() { 22 + const ax = useAnalytics() 22 23 const enabled = useInAppBrowser() 23 24 const t = useTheme() 24 25 const dialogContext = useDialogContext() ··· 31 32 } 32 33 33 34 if (!isBskyAppUrl(url)) { 34 - logEvent('link:clicked', { 35 + ax.metric('link:clicked', { 35 36 domain: toNiceDomain(url), 36 37 url, 37 38 }) ··· 72 73 } 73 74 Linking.openURL(url) 74 75 }, 75 - [enabled, inAppBrowserConsentControl, t, dialogContext], 76 + [ax, enabled, inAppBrowserConsentControl, t, dialogContext], 76 77 ) 77 78 78 79 return openLink
+9 -13
src/lib/hooks/usePostViewTracking.ts
··· 1 1 import {useCallback, useRef} from 'react' 2 2 import {type AppBskyFeedDefs} from '@atproto/api' 3 3 4 - import {logger} from '#/logger' 5 - import {type MetricEvents} from '#/logger/metrics' 4 + import {type Metrics, useAnalytics} from '#/analytics' 6 5 7 6 /** 8 7 * Hook that returns a callback to track post:view events. ··· 12 11 * @returns A callback that accepts a post and logs the view event 13 12 */ 14 13 export function usePostViewTracking( 15 - logContext: MetricEvents['post:view']['logContext'], 14 + logContext: Metrics['post:view']['logContext'], 16 15 ) { 16 + const ax = useAnalytics() 17 17 const seenUrisRef = useRef(new Set<string>()) 18 18 19 19 const trackPostView = useCallback( ··· 21 21 if (seenUrisRef.current.has(post.uri)) return 22 22 seenUrisRef.current.add(post.uri) 23 23 24 - logger.metric( 25 - 'post:view', 26 - { 27 - uri: post.uri, 28 - authorDid: post.author.did, 29 - logContext, 30 - }, 31 - {statsig: false}, 32 - ) 24 + ax.metric('post:view', { 25 + uri: post.uri, 26 + authorDid: post.author.did, 27 + logContext, 28 + }) 33 29 }, 34 - [logContext], 30 + [ax, logContext], 35 31 ) 36 32 37 33 return trackPostView
+5 -5
src/lib/notifications/notifications.ts
··· 2 2 import {Platform} from 'react-native' 3 3 import * as Notifications from 'expo-notifications' 4 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5 - import {type AtpAgent} from '@atproto/api' 6 - import {type AppBskyNotificationRegisterPush} from '@atproto/api' 5 + import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' 7 6 import debounce from 'lodash.debounce' 8 7 9 8 import { ··· 16 15 import {type SessionAccount, useAgent, useSession} from '#/state/session' 17 16 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 18 17 import {useAgeAssurance} from '#/ageAssurance' 19 - import {IS_NATIVE} from '#/env' 20 - import {IS_DEV} from '#/env' 18 + import {useAnalytics} from '#/analytics' 19 + import {IS_DEV, IS_NATIVE} from '#/env' 21 20 22 21 /** 23 22 * @private ··· 227 226 } 228 227 229 228 export function useRequestNotificationsPermission() { 229 + const ax = useAnalytics() 230 230 const {currentAccount} = useSession() 231 231 const getAndRegisterPushToken = useGetAndRegisterPushToken() 232 232 ··· 251 251 252 252 const res = await Notifications.requestPermissionsAsync() 253 253 254 - notyLogger.metric(`notifications:request`, { 254 + ax.metric(`notifications:request`, { 255 255 context: context, 256 256 status: res.status, 257 257 })
+27
src/lib/once.ts
··· 1 + import {useCallback, useRef} from 'react' 2 + 3 + type Cb = () => void 4 + 5 + export function callOnce() { 6 + let ran = false 7 + return function runCallbackOnce(cb: Cb) { 8 + if (ran) return 9 + ran = true 10 + cb() 11 + } 12 + } 13 + 14 + export function useCallOnce(cb: Cb): () => void 15 + export function useCallOnce(cb?: undefined): (cb: Cb) => void 16 + export function useCallOnce(cb?: Cb) { 17 + const ran = useRef(false) 18 + return useCallback( 19 + (icb: Cb) => { 20 + if (ran.current) return 21 + ran.current = true 22 + if (icb) icb() 23 + else if (cb) cb() 24 + }, 25 + [cb], 26 + ) 27 + }
-7
src/lib/statsig/gates.ts
··· 1 - export type Gate = 2 - // Keep this alphabetic please. 3 - | 'debug_show_feedcontext' 4 - | 'is_bsky_team_member' // special, do not remove 5 - | 'disable_onboarding_find_contacts' 6 - | 'disable_settings_find_contacts' 7 - | 'disable_live_now_beta'
-327
src/lib/statsig/statsig.tsx
··· 1 - import React from 'react' 2 - import {Platform} from 'react-native' 3 - import {AppState, type AppStateStatus} from 'react-native' 4 - import {Statsig, StatsigProvider} from 'statsig-react-native-expo' 5 - 6 - import {logger} from '#/logger' 7 - import {type MetricEvents} from '#/logger/metrics' 8 - import * as persisted from '#/state/persisted' 9 - import {IS_WEB} from '#/env' 10 - import * as env from '#/env' 11 - import {useSession} from '../../state/session' 12 - import {timeout} from '../async/timeout' 13 - import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' 14 - import {type Gate} from './gates' 15 - 16 - const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' 17 - 18 - export const initPromise = initialize() 19 - 20 - type StatsigUser = { 21 - userID: string | undefined 22 - // TODO: Remove when enough users have custom.platform: 23 - platform: 'ios' | 'android' | 'web' 24 - custom: { 25 - // This is the place where we can add our own stuff. 26 - // Fields here have to be non-optional to be visible in the UI. 27 - platform: 'ios' | 'android' | 'web' 28 - appVersion: string 29 - bundleIdentifier: string 30 - bundleDate: number 31 - refSrc: string 32 - refUrl: string 33 - appLanguage: string 34 - contentLanguages: string[] 35 - } 36 - } 37 - 38 - let refSrc = '' 39 - let refUrl = '' 40 - if (IS_WEB && typeof window !== 'undefined') { 41 - const params = new URLSearchParams(window.location.search) 42 - refSrc = params.get('ref_src') ?? '' 43 - refUrl = decodeURIComponent(params.get('ref_url') ?? '') 44 - } 45 - 46 - export type {MetricEvents as LogEvents} 47 - 48 - function createStatsigOptions(prefetchUsers: StatsigUser[]) { 49 - return { 50 - environment: { 51 - tier: env.IS_DEV 52 - ? 'development' 53 - : env.IS_TESTFLIGHT 54 - ? 'staging' 55 - : 'production', 56 - }, 57 - // Don't block on waiting for network. The fetched config will kick in on next load. 58 - // This ensures the UI is always consistent and doesn't update mid-session. 59 - // Note this makes cold load (no local storage) and private mode return `false` for all gates. 60 - initTimeoutMs: 1, 61 - // Get fresh flags for other accounts as well, if any. 62 - prefetchUsers, 63 - api: 'https://events.bsky.app/v2', 64 - } 65 - } 66 - 67 - type FlatJSONRecord = Record< 68 - string, 69 - | string 70 - | number 71 - | boolean 72 - | null 73 - | undefined 74 - // Technically not scalar but Statsig will stringify it which works for us: 75 - | string[] 76 - > 77 - 78 - let getCurrentRouteName: () => string | null | undefined = () => null 79 - 80 - export function attachRouteToLogEvents( 81 - getRouteName: () => string | null | undefined, 82 - ) { 83 - getCurrentRouteName = getRouteName 84 - } 85 - 86 - export function toClout(n: number | null | undefined): number | undefined { 87 - if (n == null) { 88 - return undefined 89 - } else { 90 - return Math.max(0, Math.round(Math.log(n))) 91 - } 92 - } 93 - 94 - /** 95 - * @deprecated use `logger.metric()` instead 96 - */ 97 - export function logEvent<E extends keyof MetricEvents>( 98 - eventName: E & string, 99 - rawMetadata: MetricEvents[E] & FlatJSONRecord, 100 - options: { 101 - /** 102 - * Send to our data lake only, not to StatSig 103 - */ 104 - lake?: boolean 105 - } = {lake: false}, 106 - ) { 107 - try { 108 - const fullMetadata = toStringRecord(rawMetadata) 109 - fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' 110 - if (Statsig.initializeCalled()) { 111 - let ev: string = eventName 112 - if (options.lake) { 113 - ev = `lake:${ev}` 114 - } 115 - Statsig.logEvent(ev, null, fullMetadata) 116 - } 117 - /** 118 - * All datalake events should be sent using `logger.metric`, and we don't 119 - * want to double-emit logs to other transports. 120 - */ 121 - if (!options.lake) { 122 - logger.info(eventName, fullMetadata) 123 - } 124 - } catch (e) { 125 - // A log should never interrupt the calling code, whatever happens. 126 - logger.error('Failed to log an event', {message: e}) 127 - } 128 - } 129 - 130 - function toStringRecord<E extends keyof MetricEvents>( 131 - metadata: MetricEvents[E] & FlatJSONRecord, 132 - ): Record<string, string> { 133 - const record: Record<string, string> = {} 134 - for (let key in metadata) { 135 - if (metadata.hasOwnProperty(key)) { 136 - if (typeof metadata[key] === 'string') { 137 - record[key] = metadata[key] 138 - } else { 139 - record[key] = JSON.stringify(metadata[key]) 140 - } 141 - } 142 - } 143 - return record 144 - } 145 - 146 - // We roll our own cache in front of Statsig because it is a singleton 147 - // and it's been difficult to get it to behave in a predictable way. 148 - // Our own cache ensures consistent evaluation within a single session. 149 - const GateCache = React.createContext<Map<string, boolean> | null>(null) 150 - GateCache.displayName = 'StatsigGateCacheContext' 151 - 152 - type GateOptions = { 153 - dangerouslyDisableExposureLogging?: boolean 154 - } 155 - 156 - export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { 157 - const cache = React.useContext(GateCache) 158 - if (!cache) { 159 - throw Error('useGate() cannot be called outside StatsigProvider.') 160 - } 161 - const gate = React.useCallback( 162 - (gateName: Gate, options: GateOptions = {}): boolean => { 163 - const cachedValue = cache.get(gateName) 164 - if (cachedValue !== undefined) { 165 - return cachedValue 166 - } 167 - let value = false 168 - if (Statsig.initializeCalled()) { 169 - if (options.dangerouslyDisableExposureLogging) { 170 - value = Statsig.checkGateWithExposureLoggingDisabled(gateName) 171 - } else { 172 - value = Statsig.checkGate(gateName) 173 - } 174 - } 175 - cache.set(gateName, value) 176 - return value 177 - }, 178 - [cache], 179 - ) 180 - return gate 181 - } 182 - 183 - /** 184 - * Debugging tool to override a gate. USE ONLY IN E2E TESTS! 185 - */ 186 - export function useDangerousSetGate(): ( 187 - gateName: Gate, 188 - value: boolean, 189 - ) => void { 190 - const cache = React.useContext(GateCache) 191 - if (!cache) { 192 - throw Error( 193 - 'useDangerousSetGate() cannot be called outside StatsigProvider.', 194 - ) 195 - } 196 - const dangerousSetGate = React.useCallback( 197 - (gateName: Gate, value: boolean) => { 198 - cache.set(gateName, value) 199 - }, 200 - [cache], 201 - ) 202 - return dangerousSetGate 203 - } 204 - 205 - function toStatsigUser(did: string | undefined): StatsigUser { 206 - const languagePrefs = persisted.get('languagePrefs') 207 - return { 208 - userID: did, 209 - platform: Platform.OS as 'ios' | 'android' | 'web', 210 - custom: { 211 - refSrc, 212 - refUrl, 213 - platform: Platform.OS as 'ios' | 'android' | 'web', 214 - appVersion: env.RELEASE_VERSION, 215 - bundleIdentifier: env.BUNDLE_IDENTIFIER, 216 - bundleDate: env.BUNDLE_DATE, 217 - appLanguage: languagePrefs.appLanguage, 218 - contentLanguages: languagePrefs.contentLanguages, 219 - }, 220 - } 221 - } 222 - 223 - let lastState: AppStateStatus = AppState.currentState 224 - let lastActive = lastState === 'active' ? performance.now() : null 225 - AppState.addEventListener('change', (state: AppStateStatus) => { 226 - if (state === lastState) { 227 - return 228 - } 229 - lastState = state 230 - if (state === 'active') { 231 - lastActive = performance.now() 232 - logEvent('state:foreground', {}) 233 - } else { 234 - let secondsActive = 0 235 - if (lastActive != null) { 236 - secondsActive = Math.round((performance.now() - lastActive) / 1e3) 237 - lastActive = null 238 - logEvent('state:background', { 239 - secondsActive, 240 - }) 241 - } 242 - } 243 - }) 244 - 245 - export async function tryFetchGates( 246 - did: string | undefined, 247 - strategy: 'prefer-low-latency' | 'prefer-fresh-gates', 248 - ) { 249 - try { 250 - let timeoutMs = 250 // Don't block the UI if we can't do this fast. 251 - if (strategy === 'prefer-fresh-gates') { 252 - // Use this for less common operations where the user would be OK with a delay. 253 - timeoutMs = 1500 254 - } 255 - if (Statsig.initializeCalled()) { 256 - await Promise.race([ 257 - timeout(timeoutMs), 258 - Statsig.prefetchUsers([toStatsigUser(did)]), 259 - ]) 260 - } 261 - } catch (e) { 262 - // Don't leak errors to the calling code, this is meant to be always safe. 263 - console.error(e) 264 - } 265 - } 266 - 267 - export function initialize() { 268 - return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) 269 - } 270 - 271 - export function Provider({children}: {children: React.ReactNode}) { 272 - const {currentAccount, accounts} = useSession() 273 - const did = currentAccount?.did 274 - const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did]) 275 - 276 - const otherDidsConcatenated = accounts 277 - .map(account => account.did) 278 - .filter(accountDid => accountDid !== did) 279 - .join(' ') // We're only interested in DID changes. 280 - const otherStatsigUsers = React.useMemo( 281 - () => otherDidsConcatenated.split(' ').map(toStatsigUser), 282 - [otherDidsConcatenated], 283 - ) 284 - const statsigOptions = React.useMemo( 285 - () => createStatsigOptions(otherStatsigUsers), 286 - [otherStatsigUsers], 287 - ) 288 - 289 - // Have our own cache in front of Statsig. 290 - // This ensures the results remain stable until the active DID changes. 291 - const [gateCache, setGateCache] = React.useState(() => new Map()) 292 - const [prevDid, setPrevDid] = React.useState(did) 293 - if (did !== prevDid) { 294 - setPrevDid(did) 295 - setGateCache(new Map()) 296 - } 297 - 298 - // Periodically poll Statsig to get the current rule evaluations for all stored accounts. 299 - // These changes are prefetched and stored, but don't get applied until the active DID changes. 300 - // This ensures that when you switch an account, it already has fresh results by then. 301 - const handleIntervalTick = useNonReactiveCallback(() => { 302 - if (Statsig.initializeCalled()) { 303 - // Note: Only first five will be taken into account by Statsig. 304 - Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers]) 305 - } 306 - }) 307 - React.useEffect(() => { 308 - const id = setInterval(handleIntervalTick, 60e3 /* 1 min */) 309 - return () => clearInterval(id) 310 - }, [handleIntervalTick]) 311 - 312 - return ( 313 - <GateCache.Provider value={gateCache}> 314 - <StatsigProvider 315 - key={did} 316 - sdkKey={SDK_KEY} 317 - mountKey={currentStatsigUser.userID} 318 - user={currentStatsigUser} 319 - // This isn't really blocking due to short initTimeoutMs above. 320 - // However, it ensures `isLoading` is always `false`. 321 - waitForInitialization={true} 322 - options={statsigOptions}> 323 - {children} 324 - </StatsigProvider> 325 - </GateCache.Provider> 326 - ) 327 - }
+38 -16
src/logger/__tests__/logger.test.ts
··· 45 45 46 46 logger.addTransport(mockTransport) 47 47 48 - const extra = {foo: true} 48 + const extra = {foo: true, __metadata__: {}} 49 + logger.warn('message', extra) 50 + 51 + expect(mockTransport).toHaveBeenCalledWith( 52 + LogLevel.Warn, 53 + undefined, 54 + 'message', 55 + extra, 56 + timestamp, 57 + ) 58 + }) 59 + 60 + test('supports inherited metadata', () => { 61 + const timestamp = Date.now() 62 + const logger = new Logger({ 63 + metadata: {bar: true}, 64 + }) 65 + 66 + const mockTransport = jest.fn() 67 + 68 + logger.addTransport(mockTransport) 69 + 70 + const extra = {foo: true, __metadata__: {bar: true}} 49 71 logger.warn('message', extra) 50 72 51 73 expect(mockTransport).toHaveBeenCalledWith( ··· 71 93 LogLevel.Warn, 72 94 undefined, 73 95 'a', 74 - {}, 96 + {__metadata__: {}}, 75 97 timestamp, 76 98 ) 77 99 ··· 81 103 LogLevel.Warn, 82 104 undefined, 83 105 'b', 84 - {}, 106 + {__metadata__: {}}, 85 107 timestamp, 86 108 ) 87 109 ··· 91 113 LogLevel.Warn, 92 114 undefined, 93 115 'c', 94 - {}, 116 + {__metadata__: {}}, 95 117 timestamp, 96 118 ) 97 119 ··· 256 278 LogLevel.Warn, 257 279 undefined, 258 280 'warn', 259 - {}, 281 + {__metadata__: {}}, 260 282 timestamp, 261 283 ) 262 284 }) ··· 276 298 LogLevel.Info, 277 299 Logger.Context.Default, 278 300 message, 279 - {}, 301 + {__metadata__: {}}, 280 302 timestamp, 281 303 ) 282 304 }) ··· 300 322 LogLevel.Debug, 301 323 'specific', 302 324 message, 303 - {}, 325 + {__metadata__: {}}, 304 326 timestamp, 305 327 ) 306 328 }) ··· 323 345 LogLevel.Debug, 324 346 'namespace:foo', 325 347 message, 326 - {}, 348 + {__metadata__: {}}, 327 349 timestamp, 328 350 ) 329 351 }) ··· 345 367 LogLevel.Debug, 346 368 'namespace:bar:baz', 347 369 message, 348 - {}, 370 + {__metadata__: {}}, 349 371 timestamp, 350 372 ) 351 373 }) ··· 367 389 LogLevel.Debug, 368 390 undefined, 369 391 message, 370 - {}, 392 + {__metadata__: {}}, 371 393 timestamp, 372 394 ) 373 395 ··· 376 398 LogLevel.Info, 377 399 undefined, 378 400 message, 379 - {}, 401 + {__metadata__: {}}, 380 402 timestamp, 381 403 ) 382 404 ··· 385 407 LogLevel.Warn, 386 408 undefined, 387 409 message, 388 - {}, 410 + {__metadata__: {}}, 389 411 timestamp, 390 412 ) 391 413 ··· 395 417 LogLevel.Error, 396 418 undefined, 397 419 e, 398 - {}, 420 + {__metadata__: {}}, 399 421 timestamp, 400 422 ) 401 423 }) ··· 418 440 LogLevel.Info, 419 441 undefined, 420 442 message, 421 - {}, 443 + {__metadata__: {}}, 422 444 timestamp, 423 445 ) 424 446 }) ··· 444 466 LogLevel.Warn, 445 467 undefined, 446 468 message, 447 - {}, 469 + {__metadata__: {}}, 448 470 timestamp, 449 471 ) 450 472 }) ··· 474 496 LogLevel.Error, 475 497 undefined, 476 498 e, 477 - {}, 499 + {__metadata__: {}}, 478 500 timestamp, 479 501 ) 480 502 })
+10 -25
src/logger/index.ts src/logger/index.tsx
··· 1 1 import {nanoid} from 'nanoid/non-secure' 2 2 3 - import {logEvent} from '#/lib/statsig/statsig' 4 3 import {add} from '#/logger/logDump' 5 - import {type MetricEvents} from '#/logger/metrics' 6 4 import {consoleTransport} from '#/logger/transports/console' 7 5 import {sentryTransport} from '#/logger/transports/sentry' 8 6 import { ··· 13 11 } from '#/logger/types' 14 12 import {enabledLogLevels} from '#/logger/util' 15 13 import {ENV} from '#/env' 16 - 17 - export {type MetricEvents as Metrics} from '#/logger/metrics' 18 14 19 15 const TRANSPORTS: Transport[] = (function configureTransports() { 20 16 switch (ENV) { ··· 37 33 level: LogLevel 38 34 context: LogContext | undefined = undefined 39 35 contextFilter: string = '' 36 + ambientMetadata: Record<string, unknown> = {} 40 37 41 38 protected debugContextRegexes: RegExp[] = [] 42 39 protected transports: Transport[] = [] 43 40 44 - static create(context?: LogContext) { 41 + static create(context?: LogContext, metadata: Record<string, unknown> = {}) { 45 42 const logger = new Logger({ 46 43 level: process.env.EXPO_PUBLIC_LOG_LEVEL as LogLevel, 47 44 context, 48 45 contextFilter: process.env.EXPO_PUBLIC_LOG_DEBUG || '', 46 + metadata, 49 47 }) 50 48 for (const transport of TRANSPORTS) { 51 49 logger.addTransport(transport) ··· 57 55 level, 58 56 context, 59 57 contextFilter, 58 + metadata: ambientMetadata = {}, 60 59 }: { 61 60 level?: LogLevel 62 61 context?: LogContext 63 62 contextFilter?: string 63 + metadata?: Record<string, unknown> 64 64 } = {}) { 65 65 this.context = context 66 66 this.level = level || LogLevel.Info 67 67 this.contextFilter = contextFilter || '' 68 + this.ambientMetadata = ambientMetadata 68 69 if (this.contextFilter) { 69 70 this.level = LogLevel.Debug 70 71 } ··· 95 96 this.transport({level: LogLevel.Error, message: error, metadata}) 96 97 } 97 98 98 - metric<E extends keyof MetricEvents>( 99 - event: E & string, 100 - metadata: MetricEvents[E], 101 - options: { 102 - /** 103 - * Optionally also send to StatSig 104 - */ 105 - statsig?: boolean 106 - } = {statsig: true}, 107 - ) { 108 - logEvent(event, metadata, { 109 - lake: !options.statsig, 110 - }) 111 - 112 - for (const transport of this.transports) { 113 - transport(LogLevel.Info, LogContext.Metric, event, metadata, Date.now()) 114 - } 115 - } 116 - 117 99 addTransport(transport: Transport) { 118 100 this.transports.push(transport) 119 101 return () => { ··· 139 121 return 140 122 141 123 const timestamp = Date.now() 142 - const meta = metadata || {} 124 + const meta: Metadata = { 125 + __metadata__: this.ambientMetadata, 126 + ...metadata, 127 + } 143 128 144 129 // send every log to syslog 145 130 add({
+11 -3
src/logger/metrics.ts src/analytics/metrics/types.ts
··· 1 + /* 2 + * Do not import runtime code into this file 3 + */ 4 + 1 5 import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' 2 6 import {type FeedDescriptor} from '#/state/queries/post-feed' 3 7 import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' 4 8 5 - export type MetricEvents = { 9 + export type Events = { 6 10 // App events 7 11 init: { 8 12 initMs: number 9 13 } 14 + 'experiment:viewed': { 15 + experimentId: string 16 + variationId: string 17 + } 18 + 10 19 'account:loggedIn': { 11 20 logContext: 12 21 | 'LoginForm' ··· 139 148 feedUrl: string 140 149 feedType: string 141 150 index: number 151 + reason?: string 142 152 } 143 153 'feed:endReached': { 144 154 feedUrl: string ··· 374 384 | 'AvatarButton' 375 385 | 'StarterPackProfilesList' 376 386 | 'FeedInterstitial' 377 - | 'ProfileHeaderSuggestedFollows' 378 387 | 'PostOnboardingFindFollows' 379 388 | 'ImmersiveVideo' 380 389 | 'ExploreSuggestedAccounts' ··· 468 477 | 'AvatarButton' 469 478 | 'StarterPackProfilesList' 470 479 | 'FeedInterstitial' 471 - | 'ProfileHeaderSuggestedFollows' 472 480 | 'PostOnboardingFindFollows' 473 481 | 'ImmersiveVideo' 474 482 | 'ExploreSuggestedAccounts'
+1 -1
src/logger/transports/console.ts
··· 36 36 if (IS_WEB) { 37 37 if (hasMetadata) { 38 38 console.groupCollapsed(msg) 39 - console.log(metadata) 39 + console.log(prepareMetadata(metadata)) 40 40 console.groupEnd() 41 41 } else { 42 42 console.log(msg)
+5
src/logger/types.ts
··· 50 50 __context__?: undefined 51 51 52 52 /** 53 + * Reserved for inherited metadata gathered in ambient context 54 + */ 55 + __metadata__?: Record<string, unknown> 56 + 57 + /** 53 58 * Applied as Sentry breadcrumb types. Defaults to `default`. 54 59 * 55 60 * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
+8
src/logger/util.ts
··· 24 24 if (value instanceof Error) { 25 25 value = value.toString() 26 26 } 27 + if ( 28 + typeof value === 'object' && 29 + value !== null && 30 + Object.keys(value).length === 0 && 31 + value.constructor === Object 32 + ) { 33 + return acc 34 + } 27 35 return {...acc, [key]: value} 28 36 }, {}) 29 37 }
+26 -14
src/screens/Bookmarks/index.tsx
··· 20 20 type CommonNavigatorParams, 21 21 type NativeStackScreenProps, 22 22 } from '#/lib/routes/types' 23 - import {logger} from '#/logger' 24 23 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 25 24 import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' 26 25 import {useSetMinimalShellMode} from '#/state/shell' ··· 37 36 import * as Skele from '#/components/Skeleton' 38 37 import * as toast from '#/components/Toast' 39 38 import {Text} from '#/components/Typography' 39 + import {useAnalytics} from '#/analytics' 40 40 import {IS_IOS} from '#/env' 41 41 42 42 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'> 43 43 44 44 export function BookmarksScreen({}: Props) { 45 45 const setMinimalShellMode = useSetMinimalShellMode() 46 + const ax = useAnalytics() 46 47 47 48 useFocusEffect( 48 49 useCallback(() => { 49 50 setMinimalShellMode(false) 50 - logger.metric('bookmarks:view', {}) 51 - }, [setMinimalShellMode]), 51 + ax.metric('bookmarks:view', {}) 52 + }, [setMinimalShellMode, ax]), 52 53 ) 53 54 54 55 return ( ··· 144 145 key: bookmark.item.uri, 145 146 bookmark: { 146 147 ...bookmark, 147 - item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>, 148 + item: bookmark.item, 148 149 }, 149 150 }) 150 151 } ··· 154 155 key: bookmark.item.uri, 155 156 bookmark: { 156 157 ...bookmark, 157 - item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>, 158 + item: bookmark.item, 158 159 }, 159 160 }) 160 161 } ··· 270 271 ) 271 272 } 272 273 274 + function BookmarkItem({ 275 + item, 276 + hideTopBorder, 277 + }: { 278 + item: Extract<ListItem, {type: 'bookmark'}> 279 + hideTopBorder: boolean 280 + }) { 281 + const ax = useAnalytics() 282 + return ( 283 + <Post 284 + post={item.bookmark.item} 285 + hideTopBorder={hideTopBorder} 286 + onBeforePress={() => { 287 + ax.metric('bookmarks:post-clicked', {}) 288 + }} 289 + /> 290 + ) 291 + } 292 + 273 293 function BookmarksEmpty() { 274 294 const t = useTheme() 275 295 const {_} = useLingui() ··· 301 321 return <BookmarksEmpty /> 302 322 } 303 323 case 'bookmark': { 304 - return ( 305 - <Post 306 - post={item.bookmark.item} 307 - hideTopBorder={index === 0} 308 - onBeforePress={() => { 309 - logger.metric('bookmarks:post-clicked', {}) 310 - }} 311 - /> 312 - ) 324 + return <BookmarkItem item={item} hideTopBorder={index === 0} /> 313 325 } 314 326 case 'bookmarkNotFound': { 315 327 return (
+3 -2
src/screens/Login/ChooseAccountForm.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 {logger} from '#/logger' 8 7 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 9 8 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 12 11 import {AccountList} from '#/components/AccountList' 13 12 import {Button, ButtonText} from '#/components/Button' 14 13 import * as TextField from '#/components/forms/TextField' 14 + import {useAnalytics} from '#/analytics' 15 15 import {FormContainer} from './FormContainer' 16 16 17 17 export const ChooseAccountForm = ({ ··· 23 23 }) => { 24 24 const [pendingDid, setPendingDid] = React.useState<string | null>(null) 25 25 const {_} = useLingui() 26 + const ax = useAnalytics() 26 27 const {currentAccount} = useSession() 27 28 const {resumeSession} = useSessionApi() 28 29 const {setShowLoggedOut} = useLoggedOutViewControls() ··· 46 47 try { 47 48 setPendingDid(account.did) 48 49 await resumeSession(account, true) 49 - logEvent('account:loggedIn', { 50 + ax.metric('account:loggedIn', { 50 51 logContext: 'ChooseAccountForm', 51 52 withPassword: false, 52 53 })
+6 -6
src/screens/Login/SetNewPasswordForm.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 - import {isNetworkError} from '#/lib/strings/errors' 8 - import {cleanError} from '#/lib/strings/errors' 6 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 9 7 import {checkAndFormatResetCode} from '#/lib/strings/password' 10 8 import {logger} from '#/logger' 11 9 import {Agent} from '#/state/session/agent' ··· 16 14 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 17 15 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 18 16 import {Text} from '#/components/Typography' 17 + import {useAnalytics} from '#/analytics' 19 18 import {FormContainer} from './FormContainer' 20 19 21 20 export const SetNewPasswordForm = ({ ··· 33 32 }) => { 34 33 const {_} = useLingui() 35 34 const t = useTheme() 35 + const ax = useAnalytics() 36 36 37 37 const [isProcessing, setIsProcessing] = useState<boolean>(false) 38 38 const [resetCode, setResetCode] = useState<string>('') ··· 49 49 msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 50 50 ), 51 51 ) 52 - logEvent('signin:passwordResetFailure', {}) 52 + ax.metric('signin:passwordResetFailure', {}) 53 53 return 54 54 } 55 55 ··· 69 69 password, 70 70 }) 71 71 onPasswordSet() 72 - logEvent('signin:passwordResetSuccess', {}) 72 + ax.metric('signin:passwordResetSuccess', {}) 73 73 } catch (e: any) { 74 74 const errMsg = e.toString() 75 75 logger.warn('Failed to set new password', {error: e}) 76 - logEvent('signin:passwordResetFailure', {}) 76 + ax.metric('signin:passwordResetFailure', {}) 77 77 setIsProcessing(false) 78 78 if (isNetworkError(e)) { 79 79 setError(
+6 -5
src/screens/Login/index.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {DEFAULT_SERVICE} from '#/lib/constants' 8 - import {logEvent} from '#/lib/statsig/statsig' 9 8 import {logger} from '#/logger' 10 9 import {useServiceQuery} from '#/state/queries/service' 11 10 import {type SessionAccount, useSession} from '#/state/session' ··· 17 16 import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' 18 17 import {atoms as a, native} from '#/alf' 19 18 import {ScreenTransition} from '#/components/ScreenTransition' 19 + import {useAnalytics} from '#/analytics' 20 20 import {ChooseAccountForm} from './ChooseAccountForm' 21 21 22 22 enum Forms { ··· 64 64 'Forward' | 'Backward' 65 65 >('Forward') 66 66 67 + const ax = useAnalytics() 67 68 const { 68 69 data: serviceDescription, 69 70 error: serviceError, ··· 96 97 logger.warn(`Failed to fetch service description for ${serviceUrl}`, { 97 98 error: String(serviceError), 98 99 }) 99 - logEvent('signin:hostingProviderFailedResolution', {}) 100 + ax.metric('signin:hostingProviderFailedResolution', {}) 100 101 } else { 101 102 setError('') 102 103 } ··· 104 105 105 106 const onPressForgotPassword = () => { 106 107 gotoForm(Forms.ForgotPassword) 107 - logEvent('signin:forgotPasswordPressed', {}) 108 + ax.metric('signin:forgotPasswordPressed', {}) 108 109 } 109 110 110 111 const handlePressBack = () => { 111 112 onPressBack() 112 113 setScreenTransitionDirection('Backward') 113 - logEvent('signin:backPressed', { 114 + ax.metric('signin:backPressed', { 114 115 failedAttemptsCount: failedAttemptCountRef.current, 115 116 }) 116 117 } 117 118 118 119 const onAttemptSuccess = () => { 119 - logEvent('signin:success', { 120 + ax.metric('signin:success', { 120 121 isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE, 121 122 timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000), 122 123 failedAttemptsCount: failedAttemptCountRef.current,
+1 -1
src/screens/Messages/ChatList.tsx
··· 7 7 import {useFocusEffect, useIsFocused} from '@react-navigation/native' 8 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 9 10 - import {useAppState} from '#/lib/hooks/useAppState' 10 + import {useAppState} from '#/lib/appState' 11 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 13 import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
+1 -1
src/screens/Messages/Inbox.tsx
··· 12 12 type UseInfiniteQueryResult, 13 13 } from '@tanstack/react-query' 14 14 15 - import {useAppState} from '#/lib/hooks/useAppState' 15 + import {useAppState} from '#/lib/appState' 16 16 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 17 17 import { 18 18 type CommonNavigatorParams,
+4 -3
src/screens/Messages/components/ChatListItem.tsx
··· 13 13 import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 14 import {useHaptics} from '#/lib/haptics' 15 15 import {decrementBadgeCount} from '#/lib/notifications/notifications' 16 - import {logEvent} from '#/lib/statsig/statsig' 17 16 import {sanitizeDisplayName} from '#/lib/strings/display-names' 18 17 import { 19 18 postUriToRelativePath, ··· 45 44 import {Text} from '#/components/Typography' 46 45 import {useSimpleVerificationState} from '#/components/verification' 47 46 import {VerificationCheck} from '#/components/verification/VerificationCheck' 47 + import {useAnalytics} from '#/analytics' 48 48 import {IS_NATIVE} from '#/env' 49 49 import type * as bsky from '#/types/bsky' 50 50 ··· 96 96 showMenu?: boolean 97 97 children?: React.ReactNode 98 98 }) { 99 + const ax = useAnalytics() 99 100 const t = useTheme() 100 101 const {_} = useLingui() 101 102 const {currentAccount} = useSession() ··· 290 291 menuControl.open() 291 292 return false 292 293 } else { 293 - logEvent('chat:open', {logContext: 'ChatsList'}) 294 + ax.metric('chat:open', {logContext: 'ChatsList'}) 294 295 } 295 296 }, 296 - [isDeletedAccount, menuControl, queryClient, profile, convo], 297 + [ax, isDeletedAccount, menuControl, queryClient, profile, convo], 297 298 ) 298 299 299 300 const onLongPress = useCallback(() => {
+5 -8
src/screens/Moderation/VerificationSettings.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {urls} from '#/lib/constants' 6 - import {logger} from '#/logger' 7 6 import { 8 7 usePreferencesQuery, 9 8 type UsePreferencesQueryResponse, ··· 17 16 import * as Layout from '#/components/Layout' 18 17 import {InlineLinkText} from '#/components/Link' 19 18 import {Loader} from '#/components/Loader' 19 + import {useAnalytics} from '#/analytics' 20 20 21 21 export function Screen() { 22 22 const {_} = useLingui() 23 + const ax = useAnalytics() 23 24 const gutters = useGutters(['base']) 24 25 const {data: preferences} = usePreferencesQuery() 25 26 ··· 51 52 }), 52 53 )} 53 54 onPress={() => { 54 - logger.metric( 55 - 'verification:learn-more', 56 - { 57 - location: 'verificationSettings', 58 - }, 59 - {statsig: true}, 60 - ) 55 + ax.metric('verification:learn-more', { 56 + location: 'verificationSettings', 57 + }) 61 58 }}> 62 59 Learn more here. 63 60 </InlineLinkText>
+9 -3
src/screens/Onboarding/StepFindContacts/index.tsx
··· 2 2 import {LayoutAnimationConfig} from 'react-native-reanimated' 3 3 import {SafeAreaView} from 'react-native-safe-area-context' 4 4 5 - import {logger} from '#/logger' 5 + import {useCallOnce} from '#/lib/once' 6 6 import {FindContactsFlow} from '#/components/contacts/FindContactsFlow' 7 7 import {type Action, type State} from '#/components/contacts/state' 8 8 import {ScreenTransition} from '#/components/ScreenTransition' 9 + import {useAnalytics} from '#/analytics' 9 10 import {useOnboardingInternalState} from '../state' 10 11 11 12 export function StepFindContacts({ ··· 16 17 flowDispatch: React.ActionDispatch<[Action]> 17 18 }) { 18 19 const {dispatch} = useOnboardingInternalState() 20 + const ax = useAnalytics() 21 + 22 + useCallOnce(() => { 23 + ax.metric('onboarding:contacts:begin', {}) 24 + })() 19 25 20 26 const [transitionDirection, setTransitionDirection] = useState< 21 27 'Forward' | 'Backward' ··· 24 30 const isFinalStep = flowState.step === '4: view matches' 25 31 const onSkip = useCallback(() => { 26 32 if (!isFinalStep) { 27 - logger.metric('onboarding:contacts:skipPressed', {}) 33 + ax.metric('onboarding:contacts:skipPressed', {}) 28 34 } 29 35 dispatch({type: 'next'}) 30 - }, [dispatch, isFinalStep]) 36 + }, [dispatch, isFinalStep, ax]) 31 37 32 38 const canGoBack = flowState.step === '2: verify number' 33 39 const onBack = useCallback(() => {
+7
src/screens/Onboarding/StepFindContactsIntro/index.tsx
··· 5 5 import {useQuery} from '@tanstack/react-query' 6 6 7 7 import {urls} from '#/lib/constants' 8 + import {useCallOnce} from '#/lib/once' 8 9 import {atoms as a} from '#/alf' 9 10 import {Admonition} from '#/components/Admonition' 10 11 import {Button, ButtonText} from '#/components/Button' 11 12 import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' 12 13 import {InlineLinkText} from '#/components/Link' 14 + import {useAnalytics} from '#/analytics' 13 15 import { 14 16 OnboardingControls, 15 17 OnboardingDescriptionText, ··· 19 21 import {useOnboardingInternalState} from '../state' 20 22 21 23 export function StepFindContactsIntro() { 24 + const ax = useAnalytics() 22 25 const {_} = useLingui() 23 26 const {dispatch} = useOnboardingInternalState() 27 + 28 + useCallOnce(() => { 29 + ax.metric('onboarding:contacts:presented', {}) 30 + })() 24 31 25 32 const {data: isAvailable, isSuccess} = useQuery({ 26 33 queryKey: ['contacts-available'],
+10 -7
src/screens/Onboarding/StepFinished/index.tsx
··· 20 20 VIDEO_SAVED_FEED, 21 21 } from '#/lib/constants' 22 22 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 23 - import {logEvent} from '#/lib/statsig/statsig' 24 23 import {logger} from '#/logger' 25 24 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 26 25 import {getAllListMembers} from '#/state/queries/list-members' ··· 46 45 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 47 46 import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 48 47 import {Loader} from '#/components/Loader' 48 + import {useAnalytics} from '#/analytics' 49 49 import {IS_WEB} from '#/env' 50 50 import * as bsky from '#/types/bsky' 51 51 import {ValuePropositionPager} from './ValuePropositionPager' 52 52 53 53 export function StepFinished() { 54 54 const {state, dispatch} = useOnboardingInternalState() 55 + const ax = useAnalytics() 55 56 const onboardDispatch = useOnboardingDispatch() 56 57 const [saving, setSaving] = useState(false) 57 58 const queryClient = useQueryClient() ··· 165 166 return next 166 167 }) 167 168 168 - logEvent('onboarding:finished:avatarResult', { 169 + ax.metric('onboarding:finished:avatarResult', { 169 170 avatarResult: profileStepResults.isCreatedAvatar 170 171 ? 'created' 171 172 : profileStepResults.image ··· 200 201 startProgressGuide('follow-10') 201 202 dispatch({type: 'finish'}) 202 203 onboardDispatch({type: 'finish'}) 203 - logEvent('onboarding:finished:nextPressed', { 204 + ax.metric('onboarding:finished:nextPressed', { 204 205 usedStarterPack: Boolean(starterPack), 205 206 starterPackName: 206 207 starterPack && ··· 216 217 feedsPinned: starterPack?.feeds?.length ?? 0, 217 218 }) 218 219 if (starterPack && listItems?.length) { 219 - logEvent('starterPack:followAll', { 220 + ax.metric('starterPack:followAll', { 220 221 logContext: 'Onboarding', 221 222 starterPack: starterPack.uri, 222 223 count: listItems?.length, 223 224 }) 224 225 } 225 226 }, [ 227 + ax, 226 228 queryClient, 227 229 agent, 228 230 dispatch, ··· 255 257 }) { 256 258 const [subStep, setSubStep] = useState<0 | 1 | 2>(0) 257 259 const {_} = useLingui() 260 + const ax = useAnalytics() 258 261 const {gtMobile} = useBreakpoints() 259 262 260 263 const onPress = () => { ··· 262 265 finishOnboarding() // has its own metrics 263 266 } else if (subStep === 1) { 264 267 setSubStep(2) 265 - logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 268 + ax.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 266 269 } else if (subStep === 0) { 267 270 setSubStep(1) 268 - logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) 271 + ax.metric('onboarding:valueProp:stepOne:nextPressed', {}) 269 272 } 270 273 } 271 274 ··· 280 283 size="small" 281 284 label={_(msg`Skip introduction and start using your account`)} 282 285 onPress={() => { 283 - logger.metric('onboarding:valueProp:skipPressed', {}) 286 + ax.metric('onboarding:valueProp:skipPressed', {}) 284 287 finishOnboarding() 285 288 }} 286 289 style={[a.bg_transparent]}>
+4 -3
src/screens/Onboarding/StepInterests/index.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {interests, useInterestsDisplayNames} from '#/lib/interests' 7 - import {logEvent} from '#/lib/statsig/statsig' 8 7 import {capitalize} from '#/lib/strings/capitalize' 9 8 import {logger} from '#/logger' 10 9 import { ··· 19 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 20 19 import * as Toggle from '#/components/forms/Toggle' 21 20 import {Loader} from '#/components/Loader' 21 + import {useAnalytics} from '#/analytics' 22 22 23 23 export function StepInterests() { 24 24 const {_} = useLingui() 25 + const ax = useAnalytics() 25 26 const interestsDisplayNames = useInterestsDisplayNames() 26 27 27 28 const {state, dispatch} = useOnboardingInternalState() ··· 40 41 selectedInterests, 41 42 }) 42 43 dispatch({type: 'next'}) 43 - logEvent('onboarding:interests:nextPressed', { 44 + ax.metric('onboarding:interests:nextPressed', { 44 45 selectedInterests, 45 46 selectedInterestsLength: selectedInterests.length, 46 47 }) ··· 48 49 logger.info(`onboading: error saving interests`) 49 50 logger.error(e) 50 51 } 51 - }, [selectedInterests, setSaving, dispatch]) 52 + }, [ax, selectedInterests, setSaving, dispatch]) 52 53 53 54 return ( 54 55 <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
+5 -5
src/screens/Onboarding/StepProfile/index.tsx
··· 14 14 import {openCropper} from '#/lib/media/picker' 15 15 import {getDataUriSize} from '#/lib/media/util' 16 16 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 17 - import {logEvent, useGate} from '#/lib/statsig/statsig' 18 17 import {isCancelledError} from '#/lib/strings/errors' 19 18 import {logger} from '#/logger' 20 19 import { ··· 37 36 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 38 37 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 39 38 import {Text} from '#/components/Typography' 39 + import {useAnalytics} from '#/analytics' 40 40 import {IS_NATIVE, IS_WEB} from '#/env' 41 41 import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' 42 42 ··· 66 66 avatarColors[Math.floor(Math.random() * avatarColors.length)] 67 67 68 68 export function StepProfile() { 69 + const ax = useAnalytics() 69 70 const {_} = useLingui() 70 71 const t = useTheme() 71 72 const {gtMobile} = useBreakpoints() 72 73 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 73 - const gate = useGate() 74 74 const requestNotificationsPermission = useRequestNotificationsPermission() 75 75 76 76 const creatorControl = Dialog.useDialogControl() ··· 89 89 90 90 React.useEffect(() => { 91 91 requestNotificationsPermission('StartOnboarding') 92 - }, [gate, requestNotificationsPermission]) 92 + }, [requestNotificationsPermission]) 93 93 94 94 const sheetWrapper = useSheetWrapper() 95 95 const openPicker = React.useCallback( ··· 156 156 } 157 157 158 158 dispatch({type: 'next'}) 159 - logEvent('onboarding:profile:nextPressed', {}) 160 - }, [avatar, dispatch]) 159 + ax.metric('onboarding:profile:nextPressed', {}) 160 + }, [ax, avatar, dispatch]) 161 161 162 162 const onDoneCreating = React.useCallback(() => { 163 163 setAvatar(prev => ({
+22 -31
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 9 9 import {wait} from '#/lib/async/wait' 10 10 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 11 11 import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 12 - import {logger} from '#/logger' 13 12 import {updateProfileShadow} from '#/state/cache/profile-shadow' 14 13 import {useLanguagePrefs} from '#/state/preferences' 15 14 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 30 29 import {Loader} from '#/components/Loader' 31 30 import * as ProfileCard from '#/components/ProfileCard' 32 31 import * as toast from '#/components/Toast' 32 + import {useAnalytics} from '#/analytics' 33 33 import {IS_WEB} from '#/env' 34 34 import type * as bsky from '#/types/bsky' 35 35 import {bulkWriteFollows} from '../util' 36 36 37 37 export function StepSuggestedAccounts() { 38 38 const {_} = useLingui() 39 + const ax = useAnalytics() 39 40 const t = useTheme() 40 41 const {gtMobile} = useBreakpoints() 41 42 const moderationOpts = useModerationOpts() ··· 92 93 93 94 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 94 95 onMutate: () => { 95 - logger.metric('onboarding:suggestedAccounts:followAllPressed', { 96 + ax.metric('onboarding:suggestedAccounts:followAllPressed', { 96 97 tab: selectedInterest ?? 'all', 97 98 numAccounts: followableDids.length, 98 99 }) ··· 132 133 (did: string, position: number) => { 133 134 if (!seenProfilesRef.current.has(did)) { 134 135 seenProfilesRef.current.add(did) 135 - logger.metric( 136 - 'suggestedUser:seen', 137 - { 138 - logContext: 'Onboarding', 139 - recId: undefined, 140 - position, 141 - suggestedDid: did, 142 - category: selectedInterest, 143 - }, 144 - {statsig: true}, 145 - ) 136 + ax.metric('suggestedUser:seen', { 137 + logContext: 'Onboarding', 138 + recId: undefined, 139 + position, 140 + suggestedDid: did, 141 + category: selectedInterest, 142 + }) 146 143 } 147 144 }, 148 - [selectedInterest], 145 + [ax, selectedInterest], 149 146 ) 150 147 151 148 return ( ··· 297 294 defaultTabLabel?: string 298 295 }) { 299 296 const {_} = useLingui() 297 + const ax = useAnalytics() 300 298 const interestsDisplayNames = useInterestsDisplayNames() 301 299 const interests = Object.keys(interestsDisplayNames) 302 300 .sort(boostInterests(popularInterests)) ··· 309 307 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 310 308 } 311 309 onSelectTab={tab => { 312 - logger.metric( 313 - 'onboarding:suggestedAccounts:tabPressed', 314 - {tab: tab}, 315 - {statsig: true}, 316 - ) 310 + ax.metric('onboarding:suggestedAccounts:tabPressed', {tab: tab}) 317 311 onSelectInterest(tab === 'all' ? null : tab) 318 312 }} 319 313 interestsDisplayNames={ ··· 343 337 onSeen: (did: string, position: number) => void 344 338 }) { 345 339 const t = useTheme() 340 + const ax = useAnalytics() 346 341 const cardRef = useRef<View>(null) 347 342 const hasTrackedRef = useRef(false) 348 343 ··· 403 398 withIcon={false} 404 399 logContext="OnboardingSuggestedAccounts" 405 400 onFollow={() => { 406 - logger.metric( 407 - 'suggestedUser:follow', 408 - { 409 - logContext: 'Onboarding', 410 - location: 'Card', 411 - recId: undefined, 412 - position, 413 - suggestedDid: profile.did, 414 - category, 415 - }, 416 - {statsig: true}, 417 - ) 401 + ax.metric('suggestedUser:follow', { 402 + logContext: 'Onboarding', 403 + location: 'Card', 404 + recId: undefined, 405 + position, 406 + suggestedDid: profile.did, 407 + category, 408 + }) 418 409 }} 419 410 /> 420 411 </ProfileCard.Header>
+3 -1
src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx
··· 19 19 import {Loader} from '#/components/Loader' 20 20 import * as Toast from '#/components/Toast' 21 21 import {Text} from '#/components/Typography' 22 + import {useAnalytics} from '#/analytics' 22 23 import * as bsky from '#/types/bsky' 23 24 24 25 const IGNORED_ACCOUNT = 'did:plc:pifkcjimdcfwaxkanzhwxufp' ··· 30 31 }) { 31 32 const t = useTheme() 32 33 const {_} = useLingui() 34 + const ax = useAnalytics() 33 35 const {currentAccount} = useSession() 34 36 const {gtPhone} = useBreakpoints() 35 37 const agent = useAgent() ··· 89 91 } 90 92 }) 91 93 Toast.show(_(msg`All accounts have been followed!`), {type: 'success'}) 92 - logger.metric('starterPack:followAll', { 94 + ax.metric('starterPack:followAll', { 93 95 logContext: 'Onboarding', 94 96 starterPack: view.uri, 95 97 count: dids.length,
+3 -3
src/screens/Onboarding/index.tsx
··· 3 3 import * as bcp47Match from 'bcp-47-match' 4 4 5 5 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 6 - import {useGate} from '#/lib/statsig/statsig' 7 6 import {useLanguagePrefs} from '#/state/preferences' 8 7 import { 9 8 Layout, ··· 23 22 import {useFindContactsFlowState} from '#/components/contacts/state' 24 23 import {Portal} from '#/components/Portal' 25 24 import {ScreenTransition} from '#/components/ScreenTransition' 25 + import {useAnalytics} from '#/analytics' 26 26 import {ENV, IS_NATIVE} from '#/env' 27 27 import {StepFindContacts} from './StepFindContacts' 28 28 import {StepFindContactsIntro} from './StepFindContactsIntro' ··· 31 31 32 32 export function Onboarding() { 33 33 const t = useTheme() 34 - const gate = useGate() 34 + const ax = useAnalytics() 35 35 36 36 const {contentLanguages} = useLanguagePrefs() 37 37 const probablySpeaksEnglish = useMemo(() => { ··· 48 48 ENV !== 'e2e' && 49 49 IS_NATIVE && 50 50 findContactsEnabled && 51 - !gate('disable_onboarding_find_contacts') 51 + !ax.features.enabled(ax.features.DisableOnboardingFindContacts) 52 52 53 53 const [state, dispatch] = useReducer( 54 54 reducer,
-9
src/screens/Onboarding/state.ts
··· 172 172 } 173 173 } 174 174 175 - if (a.type === 'next') { 176 - if (next.activeStep === 'find-contacts-intro') { 177 - logger.metric('onboarding:contacts:presented', {}) 178 - } 179 - if (next.activeStep === 'find-contacts') { 180 - logger.metric('onboarding:contacts:begin', {}) 181 - } 182 - } 183 - 184 175 const state = { 185 176 ...next, 186 177 hasPrev: next.activeStep !== 'profile',
+1 -1
src/screens/PostThread/components/GrowthHack.tsx
··· 2 2 import {View} from 'react-native' 3 3 import {PrivacySensitive} from 'expo-privacy-sensitive' 4 4 5 - import {useAppState} from '#/lib/hooks/useAppState' 5 + import {useAppState} from '#/lib/appState' 6 6 import {atoms as a, useTheme} from '#/alf' 7 7 import {sizes as iconSizes} from '#/components/icons/common' 8 8 import {Mark as Logo} from '#/components/icons/Logo'
+3 -2
src/screens/PostThread/components/HeaderDropdown.tsx
··· 2 2 import {useLingui} from '@lingui/react' 3 3 4 4 import {HITSLOP_10} from '#/lib/constants' 5 - import {logger} from '#/logger' 6 5 import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 7 6 import {Button, ButtonIcon} from '#/components/Button' 8 7 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 9 8 import * as Menu from '#/components/Menu' 9 + import {useAnalytics} from '#/analytics' 10 10 11 11 export function HeaderDropdown({ 12 12 sort, ··· 17 17 ThreadPreferences, 18 18 'sort' | 'setSort' | 'view' | 'setView' 19 19 >): React.ReactNode { 20 + const ax = useAnalytics() 20 21 const {_} = useLingui() 21 22 return ( 22 23 <Menu.Root> ··· 30 31 shape="round" 31 32 hitSlop={HITSLOP_10} 32 33 onPress={() => { 33 - logger.metric('thread:click:headerMenuOpen', {}) 34 + ax.metric('thread:click:headerMenuOpen', {}) 34 35 onPress() 35 36 }} 36 37 {...props}>
+7 -5
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 18 18 import {sanitizeHandle} from '#/lib/strings/handles' 19 19 import {niceDate} from '#/lib/strings/time' 20 20 import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 21 - import {logger} from '#/logger' 22 21 import { 23 22 POST_TOMBSTONE, 24 23 type Shadow, ··· 60 59 import {Text} from '#/components/Typography' 61 60 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 62 61 import {WhoCanReply} from '#/components/WhoCanReply' 62 + import {useAnalytics} from '#/analytics' 63 63 import * as bsky from '#/types/bsky' 64 64 65 65 export function ThreadItemAnchor({ ··· 177 177 postSource?: PostSource 178 178 }) { 179 179 const t = useTheme() 180 + const ax = useAnalytics() 180 181 const {_} = useLingui() 181 182 const {openComposer} = useOpenComposer() 182 183 const {currentAccount, hasSession} = useSession() ··· 281 282 ]) 282 283 283 284 const onOpenAuthor = () => { 284 - logger.metric('post:clickthroughAuthor', { 285 + ax.metric('post:clickthroughAuthor', { 285 286 uri: post.uri, 286 287 authorDid: post.author.did, 287 288 logContext: 'PostThreadItem', ··· 298 299 } 299 300 300 301 const onOpenEmbed = () => { 301 - logger.metric('post:clickthroughEmbed', { 302 + ax.metric('post:clickthroughEmbed', { 302 303 uri: post.uri, 303 304 authorDid: post.author.did, 304 305 logContext: 'PostThreadItem', ··· 540 541 isThreadAuthor: boolean 541 542 }) { 542 543 const t = useTheme() 544 + const ax = useAnalytics() 543 545 const {_, i18n} = useLingui() 544 546 const translate = useTranslate() 545 547 const isRootPost = !('reply' in post.record) ··· 565 567 AppBskyFeedPost.isRecord, 566 568 ) 567 569 ) { 568 - logger.metric('translate', { 570 + ax.metric('translate', { 569 571 sourceLanguages: post.record.langs ?? [], 570 572 targetLanguage: langPrefs.primaryLanguage, 571 573 textLength: post.record.text.length, ··· 574 576 575 577 return false 576 578 }, 577 - [translate, langPrefs, post], 579 + [ax, translate, langPrefs, post], 578 580 ) 579 581 580 582 return (
+4 -3
src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logger} from '#/logger' 6 5 import {atoms as a, useTheme} from '#/alf' 7 6 import {Button} from '#/components/Button' 8 7 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 9 8 import {Text} from '#/components/Typography' 9 + import {useAnalytics} from '#/analytics' 10 10 11 11 export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { 12 + const t = useTheme() 13 + const ax = useAnalytics() 12 14 const {_} = useLingui() 13 - const t = useTheme() 14 15 const label = _(msg`Show more replies`) 15 16 16 17 return ( 17 18 <Button 18 19 onPress={() => { 19 20 onPress() 20 - logger.metric('thread:click:showOtherReplies', {}) 21 + ax.metric('thread:click:showOtherReplies', {}) 21 22 }} 22 23 label={label}> 23 24 {({hovered, pressed}) => (
+9 -12
src/screens/PostThread/index.tsx
··· 6 6 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 8 import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 9 - import {logger} from '#/logger' 10 9 import {useFeedFeedback} from '#/state/feed-feedback' 11 10 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 12 11 import { ··· 44 43 import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 45 44 import * as Layout from '#/components/Layout' 46 45 import {ListFooter} from '#/components/Lists' 46 + import {useAnalytics} from '#/analytics' 47 47 48 48 const PARENT_CHUNK_SIZE = 5 49 49 const CHILDREN_CHUNK_SIZE = 50 50 50 51 51 export function PostThread({uri}: {uri: string}) { 52 + const ax = useAnalytics() 52 53 const {gtMobile} = useBreakpoints() 53 54 const {hasSession} = useSession() 54 55 const initialNumToRender = useInitialNumToRender() ··· 84 85 const post = anchor.value.post 85 86 seenPostUriRef.current = post.uri 86 87 87 - logger.metric( 88 - 'post:view', 89 - { 90 - uri: post.uri, 91 - authorDid: post.author.did, 92 - logContext: 'Post', 93 - feedDescriptor: feedFeedback.feedDescriptor, 94 - }, 95 - {statsig: false}, 96 - ) 88 + ax.metric('post:view', { 89 + uri: post.uri, 90 + authorDid: post.author.did, 91 + logContext: 'Post', 92 + feedDescriptor: feedFeedback.feedDescriptor, 93 + }) 97 94 } 98 - }, [anchor, feedFeedback.feedDescriptor]) 95 + }, [ax, anchor, feedFeedback.feedDescriptor]) 99 96 100 97 // Track post:view events for parent posts and replies (non-anchor posts) 101 98 const trackThreadItemView = usePostViewTracking('PostThreadItem')
+8 -7
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 14 14 import {MAX_LABELERS} from '#/lib/constants' 15 15 import {useHaptics} from '#/lib/haptics' 16 16 import {isAppLabeler} from '#/lib/moderation' 17 - import {logger} from '#/logger' 18 17 import {useProfileShadow} from '#/state/cache/profile-shadow' 19 18 import {type Shadow} from '#/state/cache/types' 20 19 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' ··· 34 33 import {RichText} from '#/components/RichText' 35 34 import * as Toast from '#/components/Toast' 36 35 import {Text} from '#/components/Typography' 36 + import {useAnalytics} from '#/analytics' 37 37 import {IS_IOS} from '#/env' 38 38 import {ProfileHeaderDisplayName} from './DisplayName' 39 39 import {EditProfileDialog} from './EditProfileDialog' ··· 61 61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 62 62 useProfileShadow(profileUnshadowed) 63 63 const t = useTheme() 64 + const ax = useAnalytics() 64 65 const {_} = useLingui() 65 66 const {currentAccount, hasSession} = useSession() 66 67 const playHaptic = useHaptics() ··· 99 100 ), 100 101 {type: 'error'}, 101 102 ) 102 - logger.error(`Failed to toggle labeler like`, {message: e.message}) 103 + ax.logger.error(`Failed to toggle labeler like`, {message: e.message}) 103 104 } 104 - }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 105 + }, [ax, labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 105 106 106 107 return ( 107 108 <ProfileHeaderShell ··· 233 234 /** disable the subscribe button */ 234 235 minimal?: boolean 235 236 }) { 237 + const t = useTheme() 238 + const ax = useAnalytics() 236 239 const {_} = useLingui() 237 - const t = useTheme() 238 240 const {currentAccount} = useSession() 239 241 const requireAuth = useRequireAuth() 240 242 const playHaptic = useHaptics() ··· 264 266 subscribe, 265 267 }) 266 268 267 - logger.metric( 269 + ax.metric( 268 270 subscribe 269 271 ? 'moderation:subscribedToLabeler' 270 272 : 'moderation:unsubscribedFromLabeler', 271 273 {}, 272 - {statsig: true}, 273 274 ) 274 275 } catch (e: any) { 275 276 reset() ··· 277 278 cantSubscribePrompt.open() 278 279 return 279 280 } 280 - logger.error(`Failed to subscribe to labeler`, {message: e.message}) 281 + ax.logger.error(`Failed to subscribe to labeler`, {message: e.message}) 281 282 } 282 283 }) 283 284 return (
+6 -12
src/screens/Profile/Header/Shell.tsx
··· 18 18 import {BACK_HITSLOP} from '#/lib/constants' 19 19 import {useHaptics} from '#/lib/haptics' 20 20 import {type NavigationProp} from '#/lib/routes/types' 21 - import {logger} from '#/logger' 22 21 import {type Shadow} from '#/state/cache/types' 23 22 import {useLightboxControls} from '#/state/lightbox' 24 23 import {useSession} from '#/state/session' ··· 34 33 import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 35 34 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 36 35 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 36 + import {useAnalytics} from '#/analytics' 37 37 import {IS_IOS} from '#/env' 38 38 import {GrowableAvatar} from './GrowableAvatar' 39 39 import {GrowableBanner} from './GrowableBanner' ··· 54 54 isPlaceholderProfile, 55 55 }: React.PropsWithChildren<Props>): React.ReactNode => { 56 56 const t = useTheme() 57 + const ax = useAnalytics() 57 58 const {currentAccount} = useSession() 58 59 const {_} = useLingui() 59 60 const {openLightbox} = useLightboxControls() ··· 116 117 117 118 useEffect(() => { 118 119 if (live.isActive) { 119 - logger.metric( 120 - 'live:view:profile', 121 - {subject: profile.did}, 122 - {statsig: true}, 123 - ) 120 + ax.metric('live:view:profile', {subject: profile.did}) 124 121 } 125 - }, [live.isActive, profile.did]) 122 + }, [ax, live.isActive, profile.did]) 126 123 127 124 const onPressAvi = useCallback(() => { 128 125 if (live.isActive) { 129 126 playHaptic('Light') 130 - logger.metric( 131 - 'live:card:open', 132 - {subject: profile.did, from: 'profile'}, 133 - {statsig: true}, 134 - ) 127 + ax.metric('live:card:open', {subject: profile.did, from: 'profile'}) 135 128 liveStatusControl.open() 136 129 } else { 137 130 const modui = moderation.ui('avatar') ··· 145 138 } 146 139 } 147 140 }, [ 141 + ax, 148 142 profile, 149 143 moderation, 150 144 _openLightbox,
+12 -10
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {useHaptics} from '#/lib/haptics' 8 - import {makeProfileLink} from '#/lib/routes/links' 9 - import {makeCustomFeedLink} from '#/lib/routes/links' 8 + import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links' 10 9 import {shareUrl} from '#/lib/sharing' 11 10 import {sanitizeHandle} from '#/lib/strings/handles' 12 11 import {toShareUrl} from '#/lib/strings/url-helpers' ··· 51 50 } from '#/components/moderation/ReportDialog' 52 51 import {RichText} from '#/components/RichText' 53 52 import {Text} from '#/components/Typography' 53 + import {useAnalytics} from '#/analytics' 54 54 import {IS_WEB} from '#/env' 55 55 56 56 export function ProfileFeedHeaderSkeleton() { ··· 86 86 export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { 87 87 const t = useTheme() 88 88 const {_, i18n} = useLingui() 89 + const ax = useAnalytics() 89 90 const {hasSession} = useSession() 90 91 const {gtMobile} = useBreakpoints() 91 92 const infoControl = Dialog.useDialogControl() ··· 120 121 if (savedFeedConfig) { 121 122 await removeFeed(savedFeedConfig) 122 123 Toast.show(_(msg`Removed from your feeds`)) 123 - logger.metric('feed:unsave', {feedUrl: info.uri}) 124 + ax.metric('feed:unsave', {feedUrl: info.uri}) 124 125 } else { 125 126 await addSavedFeeds([ 126 127 { ··· 130 131 }, 131 132 ]) 132 133 Toast.show(_(msg`Saved to your feeds`)) 133 - logger.metric('feed:save', {feedUrl: info.uri}) 134 + ax.metric('feed:save', {feedUrl: info.uri}) 134 135 } 135 136 } catch (err) { 136 137 Toast.show( ··· 158 159 159 160 if (pinned) { 160 161 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 161 - logger.metric('feed:pin', {feedUrl: info.uri}) 162 + ax.metric('feed:pin', {feedUrl: info.uri}) 162 163 } else { 163 164 Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) 164 - logger.metric('feed:unpin', {feedUrl: info.uri}) 165 + ax.metric('feed:unpin', {feedUrl: info.uri}) 165 166 } 166 167 } else { 167 168 await addSavedFeeds([ ··· 172 173 }, 173 174 ]) 174 175 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 175 - logger.metric('feed:pin', {feedUrl: info.uri}) 176 + ax.metric('feed:pin', {feedUrl: info.uri}) 176 177 } 177 178 } catch (e) { 178 179 Toast.show(_(msg`There was an issue contacting the server`), 'xmark') ··· 388 389 }) { 389 390 const t = useTheme() 390 391 const {_} = useLingui() 392 + const ax = useAnalytics() 391 393 const {hasSession} = useSession() 392 394 const playHaptic = useHaptics() 393 395 const control = Dialog.useDialogContext() ··· 407 409 if (isLiked && likeUri) { 408 410 await unlikeFeed({uri: likeUri}) 409 411 setLikeUri('') 410 - logger.metric('feed:unlike', {feedUrl: info.uri}) 412 + ax.metric('feed:unlike', {feedUrl: info.uri}) 411 413 } else { 412 414 const res = await likeFeed({uri: info.uri, cid: info.cid}) 413 415 setLikeUri(res.uri) 414 - logger.metric('feed:like', {feedUrl: info.uri}) 416 + ax.metric('feed:like', {feedUrl: info.uri}) 415 417 } 416 418 } catch (err) { 417 419 Toast.show( ··· 428 430 playHaptic() 429 431 const url = toShareUrl(info.route.href) 430 432 shareUrl(url) 431 - logger.metric('feed:share', {feedUrl: info.uri}) 433 + ax.metric('feed:share', {feedUrl: info.uri}) 432 434 }, [info, playHaptic]) 433 435 434 436 const onPressReport = React.useCallback(() => {
+4 -10
src/screens/ProfileList/components/Header.tsx
··· 21 21 import {Loader} from '#/components/Loader' 22 22 import {RichText} from '#/components/RichText' 23 23 import * as Toast from '#/components/Toast' 24 + import {useAnalytics} from '#/analytics' 24 25 import {MoreOptionsMenu} from './MoreOptionsMenu' 25 26 import {SubscribeMenu} from './SubscribeMenu' 26 27 ··· 34 35 preferences: UsePreferencesQueryResponse 35 36 }) { 36 37 const {_} = useLingui() 38 + const ax = useAnalytics() 37 39 const {currentAccount} = useSession() 38 40 const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 39 41 const isModList = list.purpose === AppBskyGraphDefs.MODLIST ··· 96 98 try { 97 99 await muteList({uri: list.uri, mute: false}) 98 100 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 99 - logger.metric( 100 - 'moderation:unsubscribedFromList', 101 - {listType: 'mute'}, 102 - {statsig: true}, 103 - ) 101 + ax.metric('moderation:unsubscribedFromList', {listType: 'mute'}) 104 102 } catch { 105 103 Toast.show( 106 104 _( ··· 114 112 try { 115 113 await blockList({uri: list.uri, block: false}) 116 114 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 117 - logger.metric( 118 - 'moderation:unsubscribedFromList', 119 - {listType: 'block'}, 120 - {statsig: true}, 121 - ) 115 + ax.metric('moderation:unsubscribedFromList', {listType: 'block'}) 122 116 } catch { 123 117 Toast.show( 124 118 _(
+4 -10
src/screens/ProfileList/components/MoreOptionsMenu.tsx
··· 33 33 } from '#/components/moderation/ReportDialog' 34 34 import * as Prompt from '#/components/Prompt' 35 35 import * as Toast from '#/components/Toast' 36 + import {useAnalytics} from '#/analytics' 36 37 import {IS_WEB} from '#/env' 37 38 38 39 export function MoreOptionsMenu({ ··· 43 44 savedFeedConfig?: AppBskyActorDefs.SavedFeed 44 45 }) { 45 46 const {_} = useLingui() 47 + const ax = useAnalytics() 46 48 const {currentAccount} = useSession() 47 49 const editListDialogControl = useDialogControl() 48 50 const deleteListPromptControl = useDialogControl() ··· 111 113 try { 112 114 await muteList({uri: list.uri, mute: false}) 113 115 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 114 - logger.metric( 115 - 'moderation:unsubscribedFromList', 116 - {listType: 'mute'}, 117 - {statsig: true}, 118 - ) 116 + ax.metric('moderation:unsubscribedFromList', {listType: 'mute'}) 119 117 } catch { 120 118 Toast.show( 121 119 _( ··· 129 127 try { 130 128 await blockList({uri: list.uri, block: false}) 131 129 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 132 - logger.metric( 133 - 'moderation:unsubscribedFromList', 134 - {listType: 'block'}, 135 - {statsig: true}, 136 - ) 130 + ax.metric('moderation:unsubscribedFromList', {listType: 'block'}) 137 131 } catch { 138 132 Toast.show( 139 133 _(
+4 -11
src/screens/ProfileList/components/SubscribeMenu.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logger} from '#/logger' 6 5 import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' 7 6 import {atoms as a} from '#/alf' 8 7 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 12 11 import * as Menu from '#/components/Menu' 13 12 import * as Prompt from '#/components/Prompt' 14 13 import * as Toast from '#/components/Toast' 14 + import {useAnalytics} from '#/analytics' 15 15 16 16 export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { 17 17 const {_} = useLingui() 18 + const ax = useAnalytics() 18 19 const subscribeMutePromptControl = Prompt.usePromptControl() 19 20 const subscribeBlockPromptControl = Prompt.usePromptControl() 20 21 ··· 29 30 try { 30 31 await muteList({uri: list.uri, mute: true}) 31 32 Toast.show(_(msg({message: 'List muted', context: 'toast'}))) 32 - logger.metric( 33 - 'moderation:subscribedToList', 34 - {listType: 'mute'}, 35 - {statsig: true}, 36 - ) 33 + ax.metric('moderation:subscribedToList', {listType: 'mute'}) 37 34 } catch { 38 35 Toast.show( 39 36 _( ··· 48 45 try { 49 46 await blockList({uri: list.uri, block: true}) 50 47 Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) 51 - logger.metric( 52 - 'moderation:subscribedToList', 53 - {listType: 'block'}, 54 - {statsig: true}, 55 - ) 48 + ax.metric('moderation:subscribedToList', {listType: 'block'}) 56 49 } catch { 57 50 Toast.show( 58 51 _(
+24 -38
src/screens/Search/Explore.tsx
··· 13 13 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 14 14 import {cleanError} from '#/lib/strings/errors' 15 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 - import {logger} from '#/logger' 17 - import {type MetricEvents} from '#/logger/metrics' 18 16 import {useLanguagePrefs} from '#/state/preferences/languages' 19 17 import {useModerationOpts} from '#/state/preferences/moderation-opts' 20 18 import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' ··· 68 66 import * as ProfileCard from '#/components/ProfileCard' 69 67 import {SubtleHover} from '#/components/SubtleHover' 70 68 import {Text} from '#/components/Typography' 69 + import {type Metrics, useAnalytics} from '#/analytics' 71 70 import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 72 71 import * as ModuleHeader from './components/ModuleHeader' 73 72 import { ··· 124 123 bottomBorder?: boolean 125 124 searchButton?: { 126 125 label: string 127 - metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 126 + metricsTag: Metrics['explore:module:searchButtonPress']['module'] 128 127 tab: 'user' | 'profile' | 'feed' 129 128 } 130 129 } ··· 135 134 icon: React.ComponentType<SVGIconProps> 136 135 searchButton?: { 137 136 label: string 138 - metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 137 + metricsTag: Metrics['explore:module:searchButtonPress']['module'] 139 138 tab: 'user' | 'profile' | 'feed' 140 139 } 141 140 hideDefaultTab?: boolean ··· 213 212 focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 214 213 headerHeight: number 215 214 }) { 215 + const ax = useAnalytics() 216 216 const {_} = useLingui() 217 217 const t = useTheme() 218 218 const {data: preferences, error: preferencesError} = usePreferencesQuery() ··· 273 273 try { 274 274 await fetchNextFeedsPage() 275 275 } catch (err) { 276 - logger.error('Failed to load more suggested follows', {message: err}) 276 + ax.logger.error('Failed to load more suggested follows', {message: err}) 277 277 } 278 278 }, [ 279 + ax, 279 280 isFetchingNextFeedsPage, 280 281 hasNextFeedsPage, 281 282 feedsError, ··· 333 334 try { 334 335 await fetchNextPageFeedPreviews() 335 336 } catch (err) { 336 - logger.error('Failed to load more feed previews', {message: err}) 337 + ax.logger.error('Failed to load more feed previews', {message: err}) 337 338 } 338 339 }, [ 340 + ax, 339 341 isPendingFeedPreviews, 340 342 isFetchingNextPageFeedPreviews, 341 343 hasNextPageFeedPreviews, ··· 492 494 if (hasPressedLoadMoreFeeds && index < 6) { 493 495 continue 494 496 } 495 - logger.metric( 496 - 'feed:suggestion:seen', 497 - {feedUrl: item.feed.uri}, 498 - {statsig: false}, 499 - ) 497 + ax.metric('feed:suggestion:seen', {feedUrl: item.feed.uri}) 500 498 } 501 499 } 502 500 if (!hasPressedLoadMoreFeeds) { ··· 609 607 return i 610 608 }, [ 611 609 _, 610 + ax, 612 611 useFullExperience, 613 612 suggestedFeeds, 614 613 preferences, ··· 729 728 <ModuleHeader.SearchButton 730 729 {...item.searchButton} 731 730 onPress={() => 732 - focusSearchInput( 733 - (item.searchButton?.tab || 'user') as 734 - | 'user' 735 - | 'profile' 736 - | 'feed', 737 - ) 731 + focusSearchInput(item.searchButton?.tab || 'user') 738 732 } 739 733 /> 740 734 )} ··· 751 745 <ModuleHeader.SearchButton 752 746 {...item.searchButton} 753 747 onPress={() => 754 - focusSearchInput( 755 - (item.searchButton?.tab || 'user') as 756 - | 'user' 757 - | 'profile' 758 - | 'feed', 759 - ) 748 + focusSearchInput(item.searchButton?.tab || 'user') 760 749 } 761 750 /> 762 751 )} ··· 822 811 if (!useFullExperience) { 823 812 return 824 813 } 825 - logger.metric('feed:suggestion:press', { 814 + ax.metric('feed:suggestion:press', { 826 815 feedUrl: item.feed.uri, 827 816 }) 828 817 }} ··· 1011 1000 } 1012 1001 }, 1013 1002 [ 1003 + ax, 1014 1004 t.atoms.border_contrast_low, 1015 1005 t.atoms.bg_contrast_25, 1016 1006 t.atoms.text_contrast_medium, ··· 1043 1033 const seenProfilesRef = useRef<Set<string>>(new Set()) 1044 1034 const onItemSeen = useCallback( 1045 1035 (item: ExploreScreenItems) => { 1046 - let module: MetricEvents['explore:module:seen']['module'] 1036 + let module: Metrics['explore:module:seen']['module'] 1047 1037 if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1048 1038 module = item.type 1049 1039 } else if (item.type === 'profile') { ··· 1054 1044 const position = suggestedFollowsModule.findIndex( 1055 1045 i => i.type === 'profile' && i.profile.did === item.profile.did, 1056 1046 ) 1057 - logger.metric( 1058 - 'suggestedUser:seen', 1059 - { 1060 - logContext: 'Explore', 1061 - recId: item.recId, 1062 - position: position !== -1 ? position - 1 : 0, // -1 to account for header 1063 - suggestedDid: item.profile.did, 1064 - category: null, 1065 - }, 1066 - {statsig: true}, 1067 - ) 1047 + ax.metric('suggestedUser:seen', { 1048 + logContext: 'Explore', 1049 + recId: item.recId, 1050 + position: position !== -1 ? position - 1 : 0, // -1 to account for header 1051 + suggestedDid: item.profile.did, 1052 + category: null, 1053 + }) 1068 1054 } 1069 1055 } else if (item.type === 'feed') { 1070 1056 module = 'suggestedFeeds' ··· 1077 1063 } 1078 1064 if (!alreadyReportedRef.current.has(module)) { 1079 1065 alreadyReportedRef.current.set(module, module) 1080 - logger.metric('explore:module:seen', {module}, {statsig: false}) 1066 + ax.metric('explore:module:seen', {module}) 1081 1067 } 1082 1068 }, 1083 - [suggestedFollowsModule], 1069 + [ax, suggestedFollowsModule], 1084 1070 ) 1085 1071 1086 1072 return (
+3 -6
src/screens/Search/components/ModuleHeader.tsx
··· 4 4 5 5 import {PressableScale} from '#/lib/custom-animations/PressableScale' 6 6 import {makeCustomFeedLink} from '#/lib/routes/links' 7 - import {logger} from '#/logger' 8 7 import {UserAvatar} from '#/view/com/util/UserAvatar' 9 8 import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf' 10 9 import {Button, ButtonIcon} from '#/components/Button' ··· 13 12 import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 14 13 import {Link} from '#/components/Link' 15 14 import {Text, type TextProps} from '#/components/Typography' 15 + import {useAnalytics} from '#/analytics' 16 16 17 17 export function Container({ 18 18 style, ··· 126 126 metricsTag: 'suggestedAccounts' | 'suggestedFeeds' 127 127 onPress?: () => void 128 128 }) { 129 + const ax = useAnalytics() 129 130 return ( 130 131 <Button 131 132 label={label} ··· 135 136 shape="round" 136 137 PressableComponent={native(PressableScale)} 137 138 onPress={() => { 138 - logger.metric( 139 - 'explore:module:searchButtonPress', 140 - {module: metricsTag}, 141 - {statsig: true}, 142 - ) 139 + ax.metric('explore:module:searchButtonPress', {module: metricsTag}) 143 140 onPress?.() 144 141 }} 145 142 style={[
+3 -6
src/screens/Search/modules/ExploreRecommendations.tsx
··· 2 2 import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 3 import {Trans} from '@lingui/macro' 4 4 5 - import {logger} from '#/logger' 6 5 import { 7 6 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, 8 7 useTrendingTopics, ··· 16 15 TrendingTopicSkeleton, 17 16 } from '#/components/TrendingTopics' 18 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 19 import {IS_WEB} from '#/env' 20 20 21 21 // Note: This module is not currently used and may be removed in the future. ··· 27 27 28 28 function Inner() { 29 29 const t = useTheme() 30 + const ax = useAnalytics() 30 31 const gutters = useGutters([0, 'compact']) 31 32 const {data: trending, error, isLoading} = useTrendingTopics() 32 33 const noRecs = !isLoading && !error && !trending?.suggested?.length ··· 88 89 key={topic.link} 89 90 topic={topic} 90 91 onPress={() => { 91 - logger.metric( 92 - 'recommendedTopic:click', 93 - {context: 'explore'}, 94 - {statsig: true}, 95 - ) 92 + ax.metric('recommendedTopic:click', {context: 'explore'}) 96 93 }}> 97 94 {({hovered}) => ( 98 95 <TrendingTopic
+20 -30
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
··· 9 9 import {logger} from '#/logger' 10 10 import {usePreferencesQuery} from '#/state/queries/preferences' 11 11 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 12 - import {useTheme} from '#/alf' 13 - import {atoms as a} from '#/alf' 12 + import {atoms as a, useTheme} from '#/alf' 14 13 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 15 14 import * as ProfileCard from '#/components/ProfileCard' 16 15 import {SubtleHover} from '#/components/SubtleHover' 16 + import {useAnalytics} from '#/analytics' 17 17 import type * as bsky from '#/types/bsky' 18 18 19 19 export function useLoadEnoughProfiles({ ··· 62 62 defaultTabLabel?: string 63 63 }) { 64 64 const {_} = useLingui() 65 + const ax = useAnalytics() 65 66 const interestsDisplayNames = useInterestsDisplayNames() 66 67 const {data: preferences} = usePreferencesQuery() 67 68 const personalizedInterests = preferences?.interests?.tags ··· 77 78 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 78 79 } 79 80 onSelectTab={tab => { 80 - logger.metric( 81 - 'explore:suggestedAccounts:tabPressed', 82 - {tab: tab}, 83 - {statsig: true}, 84 - ) 81 + ax.metric('explore:suggestedAccounts:tabPressed', {tab: tab}) 85 82 onSelectInterest(tab === 'all' ? null : tab) 86 83 }} 87 84 interestsDisplayNames={ ··· 112 109 position: number 113 110 }): React.ReactNode => { 114 111 const t = useTheme() 112 + const ax = useAnalytics() 115 113 return ( 116 114 <ProfileCard.Link 117 115 profile={profile} 118 116 style={[a.flex_1]} 119 117 onPress={() => { 120 - logger.metric( 121 - 'suggestedUser:press', 122 - { 123 - logContext: 'Explore', 124 - recId, 125 - position, 126 - suggestedDid: profile.did, 127 - category: null, 128 - }, 129 - {statsig: true}, 130 - ) 118 + ax.metric('suggestedUser:press', { 119 + logContext: 'Explore', 120 + recId, 121 + position, 122 + suggestedDid: profile.did, 123 + category: null, 124 + }) 131 125 }}> 132 126 {s => ( 133 127 <> ··· 157 151 withIcon={false} 158 152 logContext="ExploreSuggestedAccounts" 159 153 onFollow={() => { 160 - logger.metric( 161 - 'suggestedUser:follow', 162 - { 163 - logContext: 'Explore', 164 - location: 'Card', 165 - recId, 166 - position, 167 - suggestedDid: profile.did, 168 - category: null, 169 - }, 170 - {statsig: true}, 171 - ) 154 + ax.metric('suggestedUser:follow', { 155 + logContext: 'Explore', 156 + location: 'Card', 157 + recId, 158 + position, 159 + suggestedDid: profile.did, 160 + category: null, 161 + }) 172 162 }} 173 163 /> 174 164 </ProfileCard.Header>
+3 -6
src/screens/Search/modules/ExploreTrendingTopics.tsx
··· 4 4 import {msg, plural, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {logger} from '#/logger' 8 7 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 8 import {useTrendingSettings} from '#/state/preferences/trending' 10 9 import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' ··· 19 18 import {Link} from '#/components/Link' 20 19 import {SubtleHover} from '#/components/SubtleHover' 21 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 22 23 23 const TOPIC_COUNT = 5 24 24 ··· 29 29 } 30 30 31 31 function Inner() { 32 + const ax = useAnalytics() 32 33 const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() 33 34 const noTopics = !isLoading && !error && !trending?.trends?.length 34 35 ··· 44 45 trend={trend} 45 46 rank={index + 1} 46 47 onPress={() => { 47 - logger.metric( 48 - 'trendingTopic:click', 49 - {context: 'explore'}, 50 - {statsig: true}, 51 - ) 48 + ax.metric('trendingTopic:click', {context: 'explore'}) 52 49 }} 53 50 /> 54 51 ))}
+3 -6
src/screens/Search/modules/ExploreTrendingVideos.tsx
··· 8 8 9 9 import {VIDEO_FEED_URI} from '#/lib/constants' 10 10 import {makeCustomFeedLink} from '#/lib/routes/links' 11 - import {logger} from '#/logger' 12 11 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 13 12 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 14 13 import {atoms as a, tokens, useGutters, useTheme} from '#/alf' ··· 20 19 CompactVideoPostCard, 21 20 CompactVideoPostCardPlaceholder, 22 21 } from '#/components/VideoPostCard' 22 + import {useAnalytics} from '#/analytics' 23 23 24 24 const CARD_WIDTH = 100 25 25 ··· 151 151 }) { 152 152 const t = useTheme() 153 153 const {_} = useLingui() 154 + const ax = useAnalytics() 154 155 const items = useMemo(() => { 155 156 return data.pages 156 157 .flatMap(page => page.slices) ··· 177 178 sourceInterstitial: 'explore', 178 179 }} 179 180 onInteract={() => { 180 - logger.metric( 181 - 'videoCard:click', 182 - {context: 'interstitial:explore'}, 183 - {statsig: true}, 184 - ) 181 + ax.metric('videoCard:click', {context: 'interstitial:explore'}) 185 182 }} 186 183 /> 187 184 </View>
+2 -4
src/screens/Settings/AboutSettings.tsx
··· 1 - import {useMemo} from 'react' 2 1 import {Platform} from 'react-native' 3 2 import {setStringAsync} from 'expo-clipboard' 4 3 import * as FileSystem from 'expo-file-system/legacy' ··· 7 6 import {useLingui} from '@lingui/react' 8 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 8 import {useMutation} from '@tanstack/react-query' 10 - import {Statsig} from 'statsig-react-native-expo' 11 9 12 10 import {STATUS_PAGE_URL} from '#/lib/constants' 13 11 import {type CommonNavigatorParams} from '#/lib/routes/types' ··· 21 19 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' 22 20 import * as Layout from '#/components/Layout' 23 21 import {Loader} from '#/components/Loader' 22 + import {getDeviceId} from '#/analytics/identifiers' 24 23 import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' 25 24 import * as env from '#/env' 26 25 import {useDemoMode} from '#/storage/hooks/demo-mode' ··· 32 31 const {_, i18n} = useLingui() 33 32 const [devModeEnabled, setDevModeEnabled] = useDevMode() 34 33 const [demoModeEnabled, setDemoModeEnabled] = useDemoMode() 35 - const stableID = useMemo(() => Statsig.getStableID(), []) 36 34 37 35 const {mutate: onClearImageCache, isPending: isClearingImageCache} = 38 36 useMutation({ ··· 146 144 }} 147 145 onPress={() => { 148 146 setStringAsync( 149 - `Build version: ${env.APP_VERSION}; Bundle info: ${env.APP_METADATA}; Bundle date: ${env.BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Anonymous ID: ${stableID}`, 147 + `Build version: ${env.APP_VERSION}; Bundle info: ${env.APP_METADATA}; Bundle date: ${env.BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Device ID: ${getDeviceId() ?? 'N/A'}`, 150 148 ) 151 149 Toast.show(_(msg`Copied build version to clipboard`)) 152 150 }}>
+6 -5
src/screens/Settings/ContentAndMediaSettings.tsx
··· 3 3 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 4 4 5 5 import {type CommonNavigatorParams} from '#/lib/routes/types' 6 - import {logEvent} from '#/lib/statsig/statsig' 7 6 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 8 7 import { 9 8 useInAppBrowser, ··· 25 24 import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 26 25 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 27 26 import * as Layout from '#/components/Layout' 27 + import {useAnalytics} from '#/analytics' 28 28 import {IS_NATIVE} from '#/env' 29 29 import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle' 30 30 ··· 34 34 > 35 35 export function ContentAndMediaSettingsScreen({}: Props) { 36 36 const {_} = useLingui() 37 + const ax = useAnalytics() 37 38 const autoplayDisabledPref = useAutoplayDisabled() 38 39 const setAutoplayDisabledPref = useSetAutoplayDisabled() 39 40 const inAppBrowserPref = useInAppBrowser() ··· 135 136 onChange={value => { 136 137 const hide = Boolean(!value) 137 138 if (hide) { 138 - logEvent('trendingTopics:hide', {context: 'settings'}) 139 + ax.metric('trendingTopics:hide', {context: 'settings'}) 139 140 } else { 140 - logEvent('trendingTopics:show', {context: 'settings'}) 141 + ax.metric('trendingTopics:show', {context: 'settings'}) 141 142 } 142 143 setTrendingDisabled(hide) 143 144 }}> ··· 157 158 onChange={value => { 158 159 const hide = Boolean(!value) 159 160 if (hide) { 160 - logEvent('trendingVideos:hide', {context: 'settings'}) 161 + ax.metric('trendingVideos:hide', {context: 'settings'}) 161 162 } else { 162 - logEvent('trendingVideos:show', {context: 'settings'}) 163 + ax.metric('trendingVideos:show', {context: 'settings'}) 163 164 } 164 165 setTrendingVideoDisabled(hide) 165 166 }}>
+12 -6
src/screens/Settings/FindContactsSettings.tsx
··· 47 47 import * as ProfileCard from '#/components/ProfileCard' 48 48 import * as Toast from '#/components/Toast' 49 49 import {Text} from '#/components/Typography' 50 + import {useAnalytics} from '#/analytics' 50 51 import {IS_NATIVE} from '#/env' 51 52 import type * as bsky from '#/types/bsky' 52 53 import {bulkWriteFollows} from '../Onboarding/util' ··· 54 55 type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'> 55 56 export function FindContactsSettingsScreen({}: Props) { 56 57 const {_} = useLingui() 58 + const ax = useAnalytics() 57 59 58 60 const {data, error, refetch} = useContactsSyncStatusQuery() 59 61 60 62 const isFocused = useIsFocused() 61 63 useEffect(() => { 62 64 if (data && isFocused) { 63 - logger.metric('contacts:settings:presented', { 65 + ax.metric('contacts:settings:presented', { 64 66 hasPreviouslySynced: !!data.syncStatus, 65 67 matchCount: data.syncStatus?.matchesCount, 66 68 }) ··· 169 171 info: AppBskyContactDefs.SyncStatus 170 172 refetchStatus: () => Promise<any> 171 173 }) { 174 + const ax = useAnalytics() 172 175 const agent = useAgent() 173 176 const queryClient = useQueryClient() 174 177 const {_} = useLingui() ··· 197 200 await agent.app.bsky.contact.dismissMatch({subject: did}) 198 201 }, 199 202 onMutate: async (did: string) => { 200 - logger.metric('contacts:settings:dismiss', {}) 203 + ax.metric('contacts:settings:dismiss', {}) 201 204 optimisticRemoveMatch(queryClient, did) 202 205 }, 203 206 onError: err => { ··· 278 281 }) { 279 282 const t = useTheme() 280 283 const {_} = useLingui() 284 + const ax = useAnalytics() 281 285 const shadow = useProfileShadow(profile) 282 286 283 287 return ( ··· 314 318 profile={profile} 315 319 moderationOpts={moderationOpts} 316 320 logContext="FindContacts" 317 - onFollow={() => logger.metric('contacts:settings:follow', {})} 321 + onFollow={() => ax.metric('contacts:settings:follow', {})} 318 322 /> 319 323 {!shadow.viewer?.following && ( 320 324 <Button ··· 343 347 isAnyUnfollowed: boolean 344 348 }) { 345 349 const {_} = useLingui() 350 + const ax = useAnalytics() 346 351 const agent = useAgent() 347 352 const queryClient = useQueryClient() 348 353 const {currentAccount} = useSession() ··· 374 379 } 375 380 } while (cursor) 376 381 377 - logger.metric('contacts:settings:followAll', { 382 + ax.metric('contacts:settings:followAll', { 378 383 followCount: didsToFollow.length, 379 384 }) 380 385 ··· 459 464 function StatusFooter({syncedAt}: {syncedAt: string}) { 460 465 const {_, i18n} = useLingui() 461 466 const t = useTheme() 467 + const ax = useAnalytics() 462 468 const agent = useAgent() 463 469 const queryClient = useQueryClient() 464 470 ··· 466 472 mutationFn: async () => { 467 473 await agent.app.bsky.contact.removeData({}) 468 474 }, 469 - onMutate: () => logger.metric('contacts:settings:removeData', {}), 475 + onMutate: () => ax.metric('contacts:settings:removeData', {}), 470 476 onSuccess: () => { 471 477 Toast.show(_(msg`Contacts removed`)) 472 478 queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>( ··· 520 526 (Date.now() - new Date(syncedAt).getTime()) / 521 527 (1000 * 60 * 60 * 24), 522 528 ) 523 - logger.metric('contacts:settings:resync', { 529 + ax.metric('contacts:settings:resync', { 524 530 daysSinceLastSync, 525 531 }) 526 532 }}
+4 -3
src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 - import {logger} from '#/logger' 8 7 import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' 9 8 import {atoms as a, platform, useTheme} from '#/alf' 10 9 import * as Toggle from '#/components/forms/Toggle' 11 10 import {Loader} from '#/components/Loader' 12 11 import {Text} from '#/components/Typography' 12 + import {useAnalytics} from '#/analytics' 13 13 import {Divider} from '../../components/SettingsList' 14 14 15 15 export function PreferenceControls({ ··· 61 61 }) { 62 62 const t = useTheme() 63 63 const {_} = useLingui() 64 + const ax = useAnalytics() 64 65 const {mutate} = useNotificationSettingsUpdateMutation() 65 66 66 67 const channels = useMemo(() => { ··· 77 78 push: change.includes('push'), 78 79 } satisfies typeof preference 79 80 80 - logger.metric('activityPreference:changeChannels', { 81 + ax.metric('activityPreference:changeChannels', { 81 82 name, 82 83 push: newPreference.push, 83 84 list: newPreference.list, ··· 98 99 include: change, 99 100 } satisfies typeof preference 100 101 101 - logger.metric('activityPreference:changeFilter', {name, value: change}) 102 + ax.metric('activityPreference:changeFilter', {name, value: change}) 102 103 103 104 mutate({ 104 105 [name]: newPreference,
+1 -1
src/screens/Settings/NotificationSettings/index.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 import {useQuery, useQueryClient} from '@tanstack/react-query' 8 8 9 - import {useAppState} from '#/lib/hooks/useAppState' 9 + import {useAppState} from '#/lib/appState' 10 10 import { 11 11 type AllNavigatorParams, 12 12 type NativeStackScreenProps,
+3 -3
src/screens/Settings/Settings.tsx
··· 15 15 type CommonNavigatorParams, 16 16 type NavigationProp, 17 17 } from '#/lib/routes/types' 18 - import {useGate} from '#/lib/statsig/statsig' 19 18 import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 19 import {sanitizeHandle} from '#/lib/strings/handles' 21 20 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 69 68 shouldShowVerificationCheckButton, 70 69 VerificationCheckButton, 71 70 } from '#/components/verification/VerificationCheckButton' 71 + import {useAnalytics} from '#/analytics' 72 72 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 73 73 import {device, useStorage} from '#/storage' 74 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75 75 76 76 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 77 77 export function SettingsScreen({}: Props) { 78 + const ax = useAnalytics() 78 79 const {_} = useLingui() 79 80 const reducedMotion = useReducedMotion() 80 81 const {logoutEveryAccount} = useSessionApi() ··· 92 93 const [showDevOptions, setShowDevOptions] = useState(false) 93 94 const findContactsEnabled = 94 95 useIsFindContactsFeatureEnabledBasedOnGeolocation() 95 - const gate = useGate() 96 96 97 97 return ( 98 98 <Layout.Screen> ··· 213 213 </SettingsList.LinkItem> 214 214 {IS_NATIVE && 215 215 findContactsEnabled && 216 - !gate('disable_settings_find_contacts') && ( 216 + !ax.features.enabled(ax.features.DisableSettingsFindContacts) && ( 217 217 <SettingsList.LinkItem 218 218 to="/settings/find-contacts" 219 219 label={_(msg`Find friends from contacts`)}>
+7 -6
src/screens/Signup/StepCaptcha/index.tsx
··· 11 11 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' 12 12 import {atoms as a, useTheme} from '#/alf' 13 13 import {FormError} from '#/components/forms/FormError' 14 - import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 15 - import {GCP_PROJECT_ID} from '#/env' 14 + import {useAnalytics} from '#/analytics' 15 + import {GCP_PROJECT_ID, IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 16 16 import {BackNextButtons} from '../BackNextButtons' 17 17 18 18 const CAPTCHA_PATH = ··· 71 71 payload?: string 72 72 }) { 73 73 const {_} = useLingui() 74 + const ax = useAnalytics() 74 75 const theme = useTheme() 75 76 const {state, dispatch} = useSignupContext() 76 77 ··· 109 110 const onSuccess = React.useCallback( 110 111 (code: string) => { 111 112 setCompleted(true) 112 - logger.metric('signup:captchaSuccess', {}, {statsig: true}) 113 + ax.metric('signup:captchaSuccess', {}) 113 114 dispatch({ 114 115 type: 'submit', 115 116 task: {verificationCode: code, mutableProcessed: false}, 116 117 }) 117 118 }, 118 - [dispatch], 119 + [ax, dispatch], 119 120 ) 120 121 121 122 const onError = React.useCallback( ··· 124 125 type: 'setError', 125 126 value: _(msg`Error receiving captcha response.`), 126 127 }) 127 - logger.metric('signup:captchaFailure', {}, {statsig: true}) 128 + ax.metric('signup:captchaFailure', {}) 128 129 logger.error('Signup Flow Error', { 129 130 registrationHandle: state.handle, 130 131 error, 131 132 }) 132 133 }, 133 - [_, dispatch, state.handle], 134 + [_, ax, dispatch, state.handle], 134 135 ) 135 136 136 137 const onBackPress = React.useCallback(() => {
+13 -16
src/screens/Signup/StepHandle/index.tsx
··· 26 26 import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 27 27 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 28 28 import {Text} from '#/components/Typography' 29 + import {useAnalytics} from '#/analytics' 29 30 import {BackNextButtons} from '../BackNextButtons' 30 31 import {HandleSuggestions} from './HandleSuggestions' 31 32 32 33 export function StepHandle() { 33 34 const {_} = useLingui() 35 + const ax = useAnalytics() 34 36 const t = useTheme() 35 37 const {state, dispatch} = useSignupContext() 36 38 const [draftValue, setDraftValue] = useState(state.handle) ··· 68 70 const {available: handleAvailable} = await checkHandleAvailability( 69 71 createFullHandle(handle, state.userDomain), 70 72 state.serviceDescription?.did ?? 'UNKNOWN', 71 - {typeahead: false}, 73 + {}, 72 74 ) 73 75 74 76 if (!handleAvailable) { 77 + ax.metric('signup:handleTaken', {typeahead: false}) 75 78 dispatch({ 76 79 type: 'setError', 77 80 value: _(msg`That username is already taken`), 78 81 field: 'handle', 79 82 }) 80 83 return 84 + } else { 85 + ax.metric('signup:handleAvailable', {typeahead: false}) 81 86 } 82 87 } catch (error) { 83 88 logger.error('Failed to check handle availability on next press', { ··· 88 93 dispatch({type: 'setIsLoading', value: false}) 89 94 } 90 95 91 - logger.metric( 92 - 'signup:nextPressed', 93 - { 94 - activeStep: state.activeStep, 95 - phoneVerificationRequired: 96 - state.serviceDescription?.phoneVerificationRequired, 97 - }, 98 - {statsig: true}, 99 - ) 96 + ax.metric('signup:nextPressed', { 97 + activeStep: state.activeStep, 98 + phoneVerificationRequired: 99 + state.serviceDescription?.phoneVerificationRequired, 100 + }) 100 101 // phoneVerificationRequired is actually whether a captcha is required 101 102 if (!state.serviceDescription?.phoneVerificationRequired) { 102 103 dispatch({ ··· 115 116 value: handle, 116 117 }) 117 118 dispatch({type: 'prev'}) 118 - logger.metric( 119 - 'signup:backPressed', 120 - {activeStep: state.activeStep}, 121 - {statsig: true}, 122 - ) 119 + ax.metric('signup:backPressed', {activeStep: state.activeStep}) 123 120 } 124 121 125 122 const hasDebounceSettled = draftValue === debouncedDraftValue ··· 202 199 state.userDomain.length * -1, 203 200 ), 204 201 ) 205 - logger.metric('signup:handleSuggestionSelected', { 202 + ax.metric('signup:handleSuggestionSelected', { 206 203 method: suggestion.method, 207 204 }) 208 205 }}
+5 -7
src/screens/Signup/StepInfo/index.tsx
··· 30 30 MIN_ACCESS_AGE, 31 31 useAgeAssuranceRegionConfigWithFallback, 32 32 } from '#/ageAssurance/util' 33 + import {useAnalytics} from '#/analytics' 33 34 import {IS_NATIVE} from '#/env' 34 35 import { 35 36 useDeviceGeolocationApi, ··· 59 60 isLoadingStarterPack: boolean 60 61 }) { 61 62 const {_} = useLingui() 63 + const ax = useAnalytics() 62 64 const {state, dispatch} = useSignupContext() 63 65 const preemptivelyCompleteActivePolicyUpdate = 64 66 usePreemptivelyCompleteActivePolicyUpdate() ··· 165 167 dispatch({type: 'setEmail', value: email}) 166 168 dispatch({type: 'setPassword', value: password}) 167 169 dispatch({type: 'next'}) 168 - logger.metric( 169 - 'signup:nextPressed', 170 - { 171 - activeStep: state.activeStep, 172 - }, 173 - {statsig: true}, 174 - ) 170 + ax.metric('signup:nextPressed', { 171 + activeStep: state.activeStep, 172 + }) 175 173 } 176 174 177 175 return (
+14 -3
src/screens/Signup/index.tsx
··· 29 29 import {InlineLinkText} from '#/components/Link' 30 30 import {ScreenTransition} from '#/components/ScreenTransition' 31 31 import {Text} from '#/components/Typography' 32 - import {IS_ANDROID} from '#/env' 33 - import {GCP_PROJECT_ID} from '#/env' 32 + import {useAnalytics} from '#/analytics' 33 + import {GCP_PROJECT_ID, IS_ANDROID} from '#/env' 34 34 import * as bsky from '#/types/bsky' 35 35 36 36 export function Signup({onPressBack}: {onPressBack: () => void}) { 37 + const ax = useAnalytics() 37 38 const {_} = useLingui() 38 39 const t = useTheme() 39 - const [state, dispatch] = useReducer(reducer, initialState) 40 + const [state, dispatch] = useReducer(reducer, { 41 + ...initialState, 42 + analytics: ax, 43 + }) 40 44 const {gtMobile} = useBreakpoints() 41 45 const submit = useSubmitSignup() 46 + 47 + useEffect(() => { 48 + dispatch({ 49 + type: 'setAnalytics', 50 + value: ax, 51 + }) 52 + }, [ax]) 42 53 43 54 const activeStarterPack = useActiveStarterPack() 44 55 const {
+27 -23
src/screens/Signup/state.ts
··· 12 12 import {cleanError} from '#/lib/strings/errors' 13 13 import {createFullHandle} from '#/lib/strings/handles' 14 14 import {getAge} from '#/lib/strings/time' 15 - import {logger} from '#/logger' 16 15 import {useSessionApi} from '#/state/session' 17 16 import {useOnboardingDispatch} from '#/state/shell' 17 + import {type AnalyticsContextType, useAnalytics} from '#/analytics' 18 18 19 19 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 20 ··· 39 39 | 'date-of-birth' 40 40 41 41 export type SignupState = { 42 + analytics?: AnalyticsContextType 43 + 42 44 hasPrev: boolean 43 45 activeStep: SignupStep 44 46 screenTransitionDirection: 'Forward' | 'Backward' ··· 65 67 } 66 68 67 69 export type SignupAction = 70 + | {type: 'setAnalytics'; value: AnalyticsContextType} 68 71 | {type: 'prev'} 69 72 | {type: 'next'} 70 73 | {type: 'finish'} ··· 83 86 | {type: 'incrementBackgroundCount'} 84 87 85 88 export const initialState: SignupState = { 89 + analytics: undefined, 90 + 86 91 hasPrev: false, 87 92 activeStep: SignupStep.INFO, 88 93 screenTransitionDirection: 'Forward', ··· 126 131 let next = {...s} 127 132 128 133 switch (a.type) { 134 + case 'setAnalytics': { 135 + next.analytics = a.value 136 + break 137 + } 129 138 case 'prev': { 130 139 if (s.activeStep !== SignupStep.INFO) { 131 140 next.screenTransitionDirection = 'Backward' ··· 194 203 next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1 195 204 196 205 // Log the field error 197 - logger.metric( 198 - 'signup:fieldError', 199 - { 200 - field: a.field, 201 - errorCount: next.fieldErrors[a.field], 202 - errorMessage: a.value, 203 - activeStep: next.activeStep, 204 - }, 205 - {statsig: true}, 206 - ) 206 + s.analytics?.metric('signup:fieldError', { 207 + field: a.field, 208 + errorCount: next.fieldErrors[a.field], 209 + errorMessage: a.value, 210 + activeStep: next.activeStep, 211 + }) 207 212 } 208 213 break 209 214 } ··· 220 225 next.backgroundCount = s.backgroundCount + 1 221 226 222 227 // Log background/foreground event during signup 223 - logger.metric( 224 - 'signup:backgrounded', 225 - { 226 - activeStep: next.activeStep, 227 - backgroundCount: next.backgroundCount, 228 - }, 229 - {statsig: true}, 230 - ) 228 + s.analytics?.metric('signup:backgrounded', { 229 + activeStep: next.activeStep, 230 + backgroundCount: next.backgroundCount, 231 + }) 231 232 break 232 233 } 233 234 } 234 235 235 236 next.hasPrev = next.activeStep !== SignupStep.INFO 236 237 237 - logger.debug('signup', next) 238 + s.analytics?.logger.debug('signup', next) 238 239 239 240 if (s.activeStep !== next.activeStep) { 240 - logger.debug('signup: step changed', {activeStep: next.activeStep}) 241 + s.analytics?.logger.debug('signup: step changed', { 242 + activeStep: next.activeStep, 243 + }) 241 244 } 242 245 243 246 return next ··· 252 255 export const useSignupContext = () => React.useContext(SignupContext) 253 256 254 257 export function useSubmitSignup() { 258 + const ax = useAnalytics() 255 259 const {_} = useLingui() 256 260 const {createAccount} = useSessionApi() 257 261 const onboardingDispatch = useOnboardingDispatch() ··· 295 299 !state.pendingSubmit?.verificationCode 296 300 ) { 297 301 dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) 298 - logger.error('Signup Flow Error', { 302 + ax.logger.error('Signup Flow Error', { 299 303 errorMessage: 'Verification captcha code was not set.', 300 304 registrationHandle: state.handle, 301 305 }) ··· 358 362 }) 359 363 dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 360 364 361 - logger.error('Signup Flow Error', { 365 + ax.logger.error('Signup Flow Error', { 362 366 errorMessage: error, 363 367 registrationHandle: state.handle, 364 368 })
+3 -2
src/screens/StarterPack/StarterPackLandingScreen.tsx
··· 13 13 14 14 import {JOINED_THIS_WEEK} from '#/lib/constants' 15 15 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 16 - import {logEvent} from '#/lib/statsig/statsig' 17 16 import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack' 18 17 import {useModerationOpts} from '#/state/preferences/moderation-opts' 19 18 import {useStarterPackQuery} from '#/state/queries/starter-packs' ··· 36 35 import * as Prompt from '#/components/Prompt' 37 36 import {RichText} from '#/components/RichText' 38 37 import {Text} from '#/components/Typography' 38 + import {useAnalytics} from '#/analytics' 39 39 import {IS_WEB, IS_WEB_MOBILE_ANDROID} from '#/env' 40 40 import * as bsky from '#/types/bsky' 41 41 ··· 119 119 }) { 120 120 const {creator, listItemsSample, feeds} = starterPack 121 121 const {_, i18n} = useLingui() 122 + const ax = useAnalytics() 122 123 const t = useTheme() 123 124 const activeStarterPack = useActiveStarterPack() 124 125 const setActiveStarterPack = useSetActiveStarterPack() ··· 146 147 } else { 147 148 onContinue() 148 149 } 149 - logEvent('starterPack:ctaPress', { 150 + ax.metric('starterPack:ctaPress', { 150 151 starterPack: starterPack.uri, 151 152 }) 152 153 }
+15 -10
src/screens/StarterPack/StarterPackScreen.tsx
··· 23 23 type CommonNavigatorParams, 24 24 type NavigationProp, 25 25 } from '#/lib/routes/types' 26 - import {logEvent} from '#/lib/statsig/statsig' 27 26 import {cleanError} from '#/lib/strings/errors' 28 27 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 29 28 import {logger} from '#/logger' ··· 33 32 import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link' 34 33 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 35 34 import {useShortenLink} from '#/state/queries/shorten-link' 36 - import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' 37 - import {useStarterPackQuery} from '#/state/queries/starter-packs' 35 + import { 36 + useDeleteStarterPackMutation, 37 + useStarterPackQuery, 38 + } from '#/state/queries/starter-packs' 38 39 import {useAgent, useSession} from '#/state/session' 39 40 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 40 41 import { ··· 71 72 import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' 72 73 import {ShareDialog} from '#/components/StarterPack/ShareDialog' 73 74 import {Text} from '#/components/Typography' 75 + import {useAnalytics} from '#/analytics' 74 76 import {IS_WEB} from '#/env' 75 77 import * as bsky from '#/types/bsky' 76 78 ··· 184 186 const showFeedsTab = Boolean(starterPack.feeds?.length) 185 187 const showPostsTab = Boolean(starterPack.list) 186 188 const {_} = useLingui() 189 + const ax = useAnalytics() 187 190 188 191 const tabs = [ 189 192 ...(showPeopleTab ? [_(msg`People`)] : []), ··· 199 202 const [imageLoaded, setImageLoaded] = React.useState(false) 200 203 201 204 React.useEffect(() => { 202 - logEvent('starterPack:opened', { 205 + ax.metric('starterPack:opened', { 203 206 starterPack: starterPack.uri, 204 207 }) 205 - }, [starterPack.uri]) 208 + }, [ax, starterPack.uri]) 206 209 207 210 const onOpenShareDialog = React.useCallback(() => { 208 211 const rkey = new AtUri(starterPack.uri).rkey ··· 243 246 ? ({headerHeight, scrollElRef}) => ( 244 247 <ProfilesList 245 248 // Validated above 246 - listUri={starterPack!.list!.uri} 249 + listUri={starterPack.list!.uri} 247 250 headerHeight={headerHeight} 248 251 // @ts-expect-error 249 252 scrollElRef={scrollElRef} ··· 266 269 ? ({headerHeight, scrollElRef}) => ( 267 270 <PostsList 268 271 // Validated above 269 - listUri={starterPack!.list!.uri} 272 + listUri={starterPack.list!.uri} 270 273 headerHeight={headerHeight} 271 274 // @ts-expect-error 272 275 scrollElRef={scrollElRef} ··· 315 318 const {record, creator} = starterPack 316 319 const isOwn = creator?.did === currentAccount?.did 317 320 const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 321 + const ax = useAnalytics() 318 322 319 323 const navigation = useNavigation<NavigationProp>() 320 324 ··· 385 389 }) 386 390 Toast.show(_(msg`All accounts have been followed!`)) 387 391 captureAction(ProgressGuideAction.Follow, dids.length) 388 - logEvent('starterPack:followAll', { 392 + ax.metric('starterPack:followAll', { 389 393 logContext: 'StarterPackProfilesList', 390 394 starterPack: starterPack.uri, 391 395 count: dids.length, ··· 516 520 }) { 517 521 const t = useTheme() 518 522 const {_} = useLingui() 523 + const ax = useAnalytics() 519 524 const {gtMobile} = useBreakpoints() 520 525 const {currentAccount} = useSession() 521 526 const reportDialogControl = useReportDialogControl() ··· 528 533 error: deleteError, 529 534 } = useDeleteStarterPackMutation({ 530 535 onSuccess: () => { 531 - logEvent('starterPack:delete', {}) 536 + ax.metric('starterPack:delete', {}) 532 537 deleteDialogControl.close(() => { 533 538 if (navigation.canGoBack()) { 534 539 navigation.popToTop() ··· 554 559 rkey: routeParams.rkey, 555 560 listUri: starterPack.list.uri, 556 561 }) 557 - logEvent('starterPack:delete', {}) 562 + ax.metric('starterPack:delete', {}) 558 563 } 559 564 560 565 return (
+4 -3
src/screens/StarterPack/Wizard/index.tsx
··· 22 22 type CommonNavigatorParams, 23 23 type NavigationProp, 24 24 } from '#/lib/routes/types' 25 - import {logEvent} from '#/lib/statsig/statsig' 26 25 import {sanitizeDisplayName} from '#/lib/strings/display-names' 27 26 import {sanitizeHandle} from '#/lib/strings/handles' 28 27 import {enforceLen} from '#/lib/strings/helpers' ··· 58 57 import {Loader} from '#/components/Loader' 59 58 import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' 60 59 import {Text} from '#/components/Typography' 60 + import {useAnalytics} from '#/analytics' 61 61 import {IS_NATIVE} from '#/env' 62 62 import type * as bsky from '#/types/bsky' 63 63 import {Provider} from './State' ··· 167 167 onSuccess?: () => void 168 168 }) { 169 169 const navigation = useNavigation<NavigationProp>() 170 + const ax = useAnalytics() 170 171 const {_} = useLingui() 171 172 const setMinimalShellMode = useSetMinimalShellMode() 172 173 const [state, dispatch] = useWizardState() ··· 222 223 223 224 const onSuccessCreate = (data: {uri: string; cid: string}) => { 224 225 const rkey = new AtUri(data.uri).rkey 225 - logEvent('starterPack:create', { 226 + ax.metric('starterPack:create', { 226 227 setName: state.name != null, 227 228 setDescription: state.description != null, 228 229 profilesCount: state.profiles.length, ··· 236 237 onSuccess?.() 237 238 } else { 238 239 navigation.replace('StarterPack', { 239 - name: profile!.handle, 240 + name: profile.handle, 240 241 rkey, 241 242 new: true, 242 243 })
+12 -18
src/screens/VideoFeed/index.tsx
··· 21 21 useSafeAreaFrame, 22 22 useSafeAreaInsets, 23 23 } from 'react-native-safe-area-context' 24 - import {useEvent} from 'expo' 25 - import {useEventListener} from 'expo' 24 + import {useEvent, useEventListener} from 'expo' 26 25 import {Image, type ImageStyle} from 'expo-image' 27 26 import {LinearGradient} from 'expo-linear-gradient' 28 27 import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video' ··· 66 65 import {useProfileShadow} from '#/state/cache/profile-shadow' 67 66 import { 68 67 FeedFeedbackProvider, 68 + useFeedFeedback, 69 69 useFeedFeedbackContext, 70 70 } from '#/state/feed-feedback' 71 - import {useFeedFeedback} from '#/state/feed-feedback' 72 71 import {useFeedInfo} from '#/state/queries/feed' 73 72 import {usePostLikeMutationQueue} from '#/state/queries/post' 74 73 import { 75 - type AuthorFilter, 76 74 type FeedPostSliceItem, 77 75 usePostFeedQuery, 78 76 } from '#/state/queries/post-feed' ··· 100 98 import {PostControls} from '#/components/PostControls' 101 99 import {RichText} from '#/components/RichText' 102 100 import {Text} from '#/components/Typography' 101 + import {useAnalytics} from '#/analytics' 103 102 import {IS_ANDROID} from '#/env' 104 103 import * as bsky from '#/types/bsky' 105 104 import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' ··· 192 191 const feedDesc = useMemo(() => { 193 192 switch (params.type) { 194 193 case 'feedgen': 195 - return `feedgen|${params.uri as string}` as const 194 + return `feedgen|${params.uri}` as const 196 195 case 'author': 197 - return `author|${params.did as string}|${ 198 - params.filter as AuthorFilter 199 - }` as const 196 + return `author|${params.did}|${params.filter}` as const 200 197 default: 201 198 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 202 199 } ··· 490 487 feedContext: string | undefined 491 488 reqId: string | undefined 492 489 }): React.ReactNode => { 490 + const ax = useAnalytics() 493 491 const postShadow = usePostShadow(post) 494 492 const {width, height} = useSafeAreaFrame() 495 493 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() ··· 507 505 // Track post:view event 508 506 if (!hasTrackedView.current) { 509 507 hasTrackedView.current = true 510 - logger.metric( 511 - 'post:view', 512 - { 513 - uri: post.uri, 514 - authorDid: post.author.did, 515 - logContext: 'ImmersiveVideo', 516 - feedDescriptor, 517 - }, 518 - {statsig: false}, 519 - ) 508 + ax.metric('post:view', { 509 + uri: post.uri, 510 + authorDid: post.author.did, 511 + logContext: 'ImmersiveVideo', 512 + feedDescriptor, 513 + }) 520 514 } 521 515 } 522 516 }, [
+38 -40
src/state/feed-feedback.tsx
··· 11 11 import throttle from 'lodash.throttle' 12 12 13 13 import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 - import {Logger} from '#/logger' 15 14 import { 16 15 type FeedSourceFeedInfo, 17 16 type FeedSourceInfo, ··· 22 21 type FeedPostSliceItem, 23 22 } from '#/state/queries/post-feed' 24 23 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 24 + import {useAnalytics} from '#/analytics' 25 25 import {useAgent} from './session' 26 26 27 27 export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] ··· 42 42 'app.bsky.feed.defs#interactionSeen', 43 43 ]) 44 44 45 - const logger = Logger.create(Logger.Context.FeedFeedback) 46 - 47 45 export type StateContext = { 48 46 enabled: boolean 49 47 onItemSeen: (item: any) => void ··· 65 63 feedSourceInfo: FeedSourceInfo | undefined, 66 64 hasSession: boolean, 67 65 ) { 66 + const ax = useAnalytics() 67 + const logger = ax.logger.useChild(ax.logger.Context.FeedFeedback) 68 68 const agent = useAgent() 69 69 70 70 const feed = ··· 85 85 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 86 86 >(new WeakSet()) 87 87 88 + const flushEvents = useCallback( 89 + (stats: AggregatedStats | null, feedDescriptor: string) => { 90 + if (stats === null) { 91 + return 92 + } 93 + 94 + if (stats.clickthroughCount > 0) { 95 + ax.metric('feed:clickthrough', { 96 + count: stats.clickthroughCount, 97 + feed: feedDescriptor, 98 + }) 99 + stats.clickthroughCount = 0 100 + } 101 + 102 + if (stats.engagedCount > 0) { 103 + ax.metric('feed:engaged', { 104 + count: stats.engagedCount, 105 + feed: feedDescriptor, 106 + }) 107 + stats.engagedCount = 0 108 + } 109 + 110 + if (stats.seenCount > 0) { 111 + ax.metric('feed:seen', { 112 + count: stats.seenCount, 113 + feed: feedDescriptor, 114 + }) 115 + stats.seenCount = 0 116 + } 117 + }, 118 + [ax], 119 + ) 120 + 88 121 const aggregatedStats = useRef<AggregatedStats | null>(null) 89 122 const throttledFlushAggregatedStats = useMemo( 90 123 () => 91 124 throttle( 92 125 () => 93 - flushToStatsig( 126 + flushEvents( 94 127 aggregatedStats.current, 95 128 feed?.feedDescriptor ?? 'unknown', 96 129 ), ··· 100 133 trailing: true, 101 134 }, 102 135 ), 103 - [feed?.feedDescriptor], 136 + [feed?.feedDescriptor, flushEvents], 104 137 ) 105 138 106 139 const sendToFeedNoDelay = useCallback(() => { ··· 130 163 ) 131 164 .catch(() => {}) // ignore upstream errors 132 165 133 - // Send to Statsig 134 166 if (aggregatedStats.current === null) { 135 167 aggregatedStats.current = createAggregatedStats() 136 168 } ··· 299 331 } 300 332 } 301 333 } 302 - 303 - function flushToStatsig(stats: AggregatedStats | null, feedDescriptor: string) { 304 - if (stats === null) { 305 - return 306 - } 307 - 308 - if (stats.clickthroughCount > 0) { 309 - logger.metric('feed:clickthrough', { 310 - count: stats.clickthroughCount, 311 - feed: feedDescriptor, 312 - }) 313 - stats.clickthroughCount = 0 314 - } 315 - 316 - if (stats.engagedCount > 0) { 317 - logger.metric('feed:engaged', { 318 - count: stats.engagedCount, 319 - feed: feedDescriptor, 320 - }) 321 - stats.engagedCount = 0 322 - } 323 - 324 - if (stats.seenCount > 0) { 325 - logger.metric( 326 - 'feed:seen', 327 - { 328 - count: stats.seenCount, 329 - feed: feedDescriptor, 330 - }, 331 - {statsig: false}, 332 - ) 333 - stats.seenCount = 0 334 - } 335 - }
+1 -1
src/state/messages/convo/index.tsx
··· 3 3 import {useFocusEffect} from '@react-navigation/native' 4 4 import {useQueryClient} from '@tanstack/react-query' 5 5 6 - import {useAppState} from '#/lib/hooks/useAppState' 6 + import {useAppState} from '#/lib/appState' 7 7 import {Convo} from '#/state/messages/convo/agent' 8 8 import { 9 9 type ConvoParams,
+12 -1
src/state/preferences/languages.tsx
··· 2 2 3 3 import {type AppLanguage} from '#/locale/languages' 4 4 import * as persisted from '#/state/persisted' 5 + import {AnalyticsContext, utils} from '#/analytics' 5 6 6 7 type SetStateCb = ( 7 8 s: persisted.Schema['languagePrefs'], ··· 124 125 125 126 return ( 126 127 <stateContext.Provider value={state}> 127 - <apiContext.Provider value={api}>{children}</apiContext.Provider> 128 + <apiContext.Provider value={api}> 129 + <AnalyticsContext 130 + metadata={utils.useMeta({ 131 + preferences: { 132 + appLanguage: state.appLanguage, 133 + contentLanguages: state.contentLanguages, 134 + }, 135 + })}> 136 + {children} 137 + </AnalyticsContext> 138 + </apiContext.Provider> 128 139 </stateContext.Provider> 129 140 ) 130 141 }
+9 -10
src/state/queries/handle-availability.ts
··· 7 7 PUBLIC_BSKY_SERVICE, 8 8 } from '#/lib/constants' 9 9 import {createFullHandle} from '#/lib/strings/handles' 10 - import {logger} from '#/logger' 11 10 import {useDebouncedValue} from '#/components/live/utils' 11 + import {useAnalytics} from '#/analytics' 12 12 import * as bsky from '#/types/bsky' 13 13 import {Agent} from '../session/agent' 14 14 ··· 36 36 }, 37 37 debounceDelayMs = 500, 38 38 ) { 39 + const ax = useAnalytics() 39 40 const name = username.trim() 40 41 const debouncedHandle = useDebouncedValue(name, debounceDelayMs) 41 42 ··· 51 52 ), 52 53 queryFn: async () => { 53 54 const handle = createFullHandle(name, serviceDomain) 54 - return await checkHandleAvailability(handle, serviceDid, { 55 + const res = await checkHandleAvailability(handle, serviceDid, { 55 56 email, 56 57 birthDate, 57 - typeahead: true, 58 58 }) 59 + if (res.available) { 60 + ax.metric('signup:handleAvailable', {typeahead: true}) 61 + } else { 62 + ax.metric('signup:handleTaken', {typeahead: true}) 63 + } 64 + return res 59 65 }, 60 66 }), 61 67 } ··· 67 73 { 68 74 email, 69 75 birthDate, 70 - typeahead, 71 76 }: { 72 77 email?: string 73 78 birthDate?: string 74 - typeahead?: boolean 75 79 }, 76 80 ) { 77 81 if (serviceDid === BSKY_SERVICE_DID) { ··· 89 93 ComAtprotoTempCheckHandleAvailability.isResultAvailable, 90 94 ) 91 95 ) { 92 - logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 93 - 94 96 return {available: true} as const 95 97 } else if ( 96 98 bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>( ··· 98 100 ComAtprotoTempCheckHandleAvailability.isResultUnavailable, 99 101 ) 100 102 ) { 101 - logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 102 103 return { 103 104 available: false, 104 105 suggestions: data.result.suggestions, ··· 117 118 }) 118 119 119 120 if (res.data.did) { 120 - logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 121 121 return {available: false} as const 122 122 } 123 123 } catch {} 124 - logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 125 124 return {available: true} as const 126 125 } 127 126 }
+16 -14
src/state/queries/post.ts
··· 3 3 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 4 4 5 5 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 6 - import {type LogEvents, toClout} from '#/lib/statsig/statsig' 7 - import {logger} from '#/logger' 8 6 import {updatePostShadow} from '#/state/cache/post-shadow' 9 7 import {type Shadow} from '#/state/cache/types' 10 8 import {useAgent, useSession} from '#/state/session' 11 9 import * as userActionHistory from '#/state/userActionHistory' 10 + import {useAnalytics} from '#/analytics' 11 + import {type Metrics, toClout} from '#/analytics/metrics' 12 12 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' 13 13 import {findProfileQueryData} from './profile' 14 14 ··· 103 103 post: Shadow<AppBskyFeedDefs.PostView>, 104 104 viaRepost: {uri: string; cid: string} | undefined, 105 105 feedDescriptor: string | undefined, 106 - logContext: LogEvents['post:like']['logContext'] & 107 - LogEvents['post:unlike']['logContext'], 106 + logContext: Metrics['post:like']['logContext'], 108 107 ) { 109 108 const queryClient = useQueryClient() 110 109 const postUri = post.uri ··· 164 163 165 164 function usePostLikeMutation( 166 165 feedDescriptor: string | undefined, 167 - logContext: LogEvents['post:like']['logContext'], 166 + logContext: Metrics['post:like']['logContext'], 168 167 post: Shadow<AppBskyFeedDefs.PostView>, 169 168 ) { 170 169 const {currentAccount} = useSession() 171 170 const queryClient = useQueryClient() 172 171 const postAuthor = post.author 173 172 const agent = useAgent() 173 + const ax = useAnalytics() 174 174 return useMutation< 175 175 {uri: string}, // responds with the uri of the like 176 176 Error, ··· 181 181 if (currentAccount) { 182 182 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 183 183 } 184 - logger.metric('post:like', { 184 + ax.metric('post:like', { 185 185 uri, 186 186 authorDid: postAuthor.did, 187 187 logContext, ··· 207 207 208 208 function usePostUnlikeMutation( 209 209 feedDescriptor: string | undefined, 210 - logContext: LogEvents['post:unlike']['logContext'], 210 + logContext: Metrics['post:unlike']['logContext'], 211 211 post: Shadow<AppBskyFeedDefs.PostView>, 212 212 ) { 213 213 const agent = useAgent() 214 + const ax = useAnalytics() 214 215 return useMutation<void, Error, {postUri: string; likeUri: string}>({ 215 216 mutationFn: ({postUri, likeUri}) => { 216 - logger.metric('post:unlike', { 217 + ax.metric('post:unlike', { 217 218 uri: postUri, 218 219 authorDid: post.author.did, 219 220 logContext, ··· 228 229 post: Shadow<AppBskyFeedDefs.PostView>, 229 230 viaRepost: {uri: string; cid: string} | undefined, 230 231 feedDescriptor: string | undefined, 231 - logContext: LogEvents['post:repost']['logContext'] & 232 - LogEvents['post:unrepost']['logContext'], 232 + logContext: Metrics['post:repost']['logContext'], 233 233 ) { 234 234 const queryClient = useQueryClient() 235 235 const postUri = post.uri ··· 291 291 292 292 function usePostRepostMutation( 293 293 feedDescriptor: string | undefined, 294 - logContext: LogEvents['post:repost']['logContext'], 294 + logContext: Metrics['post:repost']['logContext'], 295 295 post: Shadow<AppBskyFeedDefs.PostView>, 296 296 ) { 297 297 const agent = useAgent() 298 + const ax = useAnalytics() 298 299 return useMutation< 299 300 {uri: string}, // responds with the uri of the repost 300 301 Error, 301 302 {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present 302 303 >({ 303 304 mutationFn: ({uri, cid, via}) => { 304 - logger.metric('post:repost', { 305 + ax.metric('post:repost', { 305 306 uri, 306 307 authorDid: post.author.did, 307 308 logContext, ··· 314 315 315 316 function usePostUnrepostMutation( 316 317 feedDescriptor: string | undefined, 317 - logContext: LogEvents['post:unrepost']['logContext'], 318 + logContext: Metrics['post:unrepost']['logContext'], 318 319 post: Shadow<AppBskyFeedDefs.PostView>, 319 320 ) { 320 321 const agent = useAgent() 322 + const ax = useAnalytics() 321 323 return useMutation<void, Error, {postUri: string; repostUri: string}>({ 322 324 mutationFn: ({postUri, repostUri}) => { 323 - logger.metric('post:unrepost', { 325 + ax.metric('post:unrepost', { 324 326 uri: postUri, 325 327 authorDid: post.author.did, 326 328 logContext,
+6 -8
src/state/queries/preferences/index.ts
··· 9 9 import {PROD_DEFAULT_FEED} from '#/lib/constants' 10 10 import {replaceEqualDeep} from '#/lib/functions' 11 11 import {getAge} from '#/lib/strings/time' 12 - import {logger} from '#/logger' 13 12 import {STALE} from '#/state/queries' 14 13 import { 15 14 DEFAULT_HOME_FEED_PREFS, ··· 24 23 import {saveLabelers} from '#/state/session/agent-config' 25 24 import {useAgeAssurance} from '#/ageAssurance' 26 25 import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' 26 + import {useAnalytics} from '#/analytics' 27 27 28 28 export * from '#/state/queries/preferences/const' 29 29 export * from '#/state/queries/preferences/moderation' ··· 110 110 } 111 111 112 112 export function usePreferencesSetContentLabelMutation() { 113 + const ax = useAnalytics() 113 114 const agent = useAgent() 114 115 const queryClient = useQueryClient() 115 116 ··· 120 121 >({ 121 122 mutationFn: async ({label, visibility, labelerDid}) => { 122 123 await agent.setContentLabelPref(label, visibility, labelerDid) 123 - logger.metric( 124 - 'moderation:changeLabelPreference', 125 - {preference: visibility}, 126 - {statsig: true}, 127 - ) 124 + ax.metric('moderation:changeLabelPreference', {preference: visibility}) 128 125 // triggers a refetch 129 126 await queryClient.invalidateQueries({ 130 127 queryKey: preferencesQueryKey, ··· 417 414 } 418 415 419 416 export function useSetVerificationPrefsMutation() { 417 + const ax = useAnalytics() 420 418 const queryClient = useQueryClient() 421 419 const agent = useAgent() 422 420 ··· 424 422 mutationFn: async prefs => { 425 423 await agent.setVerificationPrefs(prefs) 426 424 if (prefs.hideBadges) { 427 - logger.metric('verification:settings:hideBadges', {}, {statsig: true}) 425 + ax.metric('verification:settings:hideBadges', {}) 428 426 } else { 429 - logger.metric('verification:settings:unHideBadges', {}, {statsig: true}) 427 + ax.metric('verification:settings:unHideBadges', {}) 430 428 } 431 429 // triggers a refetch 432 430 await queryClient.invalidateQueries({
+7 -6
src/state/queries/preferences/useThreadPreferences.ts
··· 2 2 import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' 3 3 import debounce from 'lodash.debounce' 4 4 5 - import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce' 6 - import {logger} from '#/logger' 5 + import {useCallOnce} from '#/lib/once' 7 6 import { 8 7 usePreferencesQuery, 9 8 useSetThreadViewPreferencesMutation, 10 9 } from '#/state/queries/preferences' 11 10 import {type ThreadViewPreferences} from '#/state/queries/preferences/types' 11 + import {useAnalytics} from '#/analytics' 12 12 import {type Literal} from '#/types/utils' 13 13 14 14 export type ThreadSortOption = Literal< ··· 28 28 export function useThreadPreferences({ 29 29 save, 30 30 }: {save?: boolean} = {}): ThreadPreferences { 31 + const ax = useAnalytics() 31 32 const {data: preferences} = usePreferencesQuery() 32 33 const serverPrefs = preferences?.threadViewPrefs 33 - const once = useCallOnce(OnceKey.PreferencesThread) 34 + const once = useCallOnce() 34 35 35 36 /* 36 37 * Create local state representations of server state ··· 61 62 ) 62 63 63 64 once(() => { 64 - logger.metric('thread:preferences:load', { 65 + ax.metric('thread:preferences:load', { 65 66 sort: serverPrefs.sort, 66 67 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 67 68 }) ··· 76 77 try { 77 78 setIsSaving(true) 78 79 await mutateAsync(prefs) 79 - logger.metric('thread:preferences:update', { 80 + ax.metric('thread:preferences:update', { 80 81 sort: prefs.sort, 81 82 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 82 83 }) 83 84 } catch (e) { 84 - logger.error('useThreadPreferences failed to save', { 85 + ax.logger.error('useThreadPreferences failed to save', { 85 86 safeMessage: e, 86 87 }) 87 88 } finally {
+9 -7
src/state/queries/profile.ts
··· 22 22 import {uploadBlob} from '#/lib/api' 23 23 import {until} from '#/lib/async/until' 24 24 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 25 - import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 26 25 import {updateProfileShadow} from '#/state/cache/profile-shadow' 27 26 import {type Shadow} from '#/state/cache/types' 28 27 import {type ImageMeta} from '#/state/gallery' ··· 36 35 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 37 36 import {useAgent, useSession} from '#/state/session' 38 37 import * as userActionHistory from '#/state/userActionHistory' 38 + import {useAnalytics} from '#/analytics' 39 + import {type Metrics, toClout} from '#/analytics/metrics' 39 40 import type * as bsky from '#/types/bsky' 40 41 import { 41 42 ProgressGuideAction, ··· 243 244 244 245 export function useProfileFollowMutationQueue( 245 246 profile: Shadow<bsky.profile.AnyProfileView>, 246 - logContext: LogEvents['profile:follow']['logContext'] & 247 - LogEvents['profile:follow']['logContext'], 247 + logContext: Metrics['profile:follow']['logContext'], 248 248 position?: number, 249 249 contextProfileDid?: string, 250 250 ) { ··· 364 364 } 365 365 366 366 function useProfileFollowMutation( 367 - logContext: LogEvents['profile:follow']['logContext'], 367 + logContext: Metrics['profile:follow']['logContext'], 368 368 profile: Shadow<bsky.profile.AnyProfileView>, 369 369 position?: number, 370 370 contextProfileDid?: string, 371 371 ) { 372 + const ax = useAnalytics() 372 373 const {currentAccount} = useSession() 373 374 const agent = useAgent() 374 375 const queryClient = useQueryClient() ··· 381 382 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 382 383 } 383 384 captureAction(ProgressGuideAction.Follow) 384 - logEvent('profile:follow', { 385 + ax.metric('profile:follow', { 385 386 logContext, 386 387 didBecomeMutual: profile.viewer 387 388 ? Boolean(profile.viewer.followedBy) ··· 401 402 } 402 403 403 404 function useProfileUnfollowMutation( 404 - logContext: LogEvents['profile:unfollow']['logContext'], 405 + logContext: Metrics['profile:unfollow']['logContext'], 405 406 ) { 407 + const ax = useAnalytics() 406 408 const agent = useAgent() 407 409 return useMutation<void, Error, {did: string; followUri: string}>({ 408 410 mutationFn: async ({followUri}) => { 409 - logEvent('profile:unfollow', {logContext}) 411 + ax.metric('profile:unfollow', {logContext}) 410 412 return await agent.deleteFollow(followUri) 411 413 }, 412 414 })
+3 -2
src/state/queries/verification/useVerificationCreateMutation.tsx
··· 2 2 import {useMutation} from '@tanstack/react-query' 3 3 4 4 import {until} from '#/lib/async/until' 5 - import {logger} from '#/logger' 6 5 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 7 6 import {useAgent, useSession} from '#/state/session' 7 + import {useAnalytics} from '#/analytics' 8 8 import type * as bsky from '#/types/bsky' 9 9 10 10 export function useVerificationCreateMutation() { 11 + const ax = useAnalytics() 11 12 const agent = useAgent() 12 13 const {currentAccount} = useSession() 13 14 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 46 47 ) 47 48 }, 48 49 async onSuccess(_, {profile}) { 49 - logger.metric('verification:create', {}, {statsig: true}) 50 + ax.metric('verification:create', {}) 50 51 await updateProfileVerificationCache({profile}) 51 52 }, 52 53 })
+3 -2
src/state/queries/verification/useVerificationsRemoveMutation.tsx
··· 6 6 import {useMutation} from '@tanstack/react-query' 7 7 8 8 import {until} from '#/lib/async/until' 9 - import {logger} from '#/logger' 10 9 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 11 10 import {useAgent, useSession} from '#/state/session' 11 + import {useAnalytics} from '#/analytics' 12 12 import type * as bsky from '#/types/bsky' 13 13 14 14 export function useVerificationsRemoveMutation() { 15 + const ax = useAnalytics() 15 16 const agent = useAgent() 16 17 const {currentAccount} = useSession() 17 18 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 56 57 ) 57 58 }, 58 59 async onSuccess(_, {profile}) { 59 - logger.metric('verification:revoke', {}, {statsig: true}) 60 + ax.metric('verification:revoke', {}) 60 61 await updateProfileVerificationCache({profile}) 61 62 }, 62 63 })
+3 -7
src/state/service-config.tsx
··· 1 1 import {createContext, useContext, useMemo} from 'react' 2 2 3 - import {useGate} from '#/lib/statsig/statsig' 4 3 import {useLanguagePrefs} from '#/state/preferences/languages' 5 4 import {useServiceConfigQuery} from '#/state/queries/service-config' 6 5 import {useSession} from '#/state/session' 6 + import {useAnalytics} from '#/analytics' 7 7 import {IS_DEV} from '#/env' 8 8 import {device} from '#/storage' 9 9 ··· 52 52 return {enabled: Boolean(cachedEnabled)} 53 53 } 54 54 55 - /* 56 - * Doing an extra check here to reduce hits to statsig. If it's disabled on 57 - * the server, we can exit early. 58 - */ 59 55 const enabled = Boolean(config?.topicsEnabled) 60 56 61 57 // update cache ··· 107 103 } 108 104 109 105 export function useCanGoLive() { 110 - const gate = useGate() 106 + const ax = useAnalytics() 111 107 const {hasSession} = useSession() 112 108 if (!hasSession) return false 113 - return IS_DEV ? true : !gate('disable_live_now_beta') 109 + return IS_DEV ? true : !ax.features.enabled(ax.features.DisableLiveNowBeta) 114 110 } 115 111 116 112 export function useCheckEmailConfirmed() {
+8 -6
src/state/session/agent.ts
··· 22 22 PUBLIC_BSKY_SERVICE, 23 23 TIMELINE_SAVED_FEED, 24 24 } from '#/lib/constants' 25 - import {tryFetchGates} from '#/lib/statsig/statsig' 26 25 import {getAge} from '#/lib/strings/time' 27 26 import {logger} from '#/logger' 28 27 import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' ··· 32 31 setBirthdateForDid, 33 32 setCreatedAtForDid, 34 33 } from '#/ageAssurance/data' 34 + import {features} from '#/analytics' 35 35 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 36 36 import {addSessionErrorLog} from './logging' 37 37 import { ··· 63 63 if (storedAccount.pdsUrl) { 64 64 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) 65 65 } 66 - const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') 66 + const gates = features.refresh({ 67 + strategy: 'prefer-low-latency', 68 + }) 67 69 const moderation = configureModerationForAccount(agent, storedAccount) 68 70 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) 69 71 if (isSessionExpired(storedAccount)) { ··· 123 125 }) 124 126 125 127 const account = agentToSessionAccountOrThrow(agent) 126 - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 128 + const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 127 129 const moderation = configureModerationForAccount(agent, account) 128 130 const aa = prefetchAgeAssuranceData({agent}) 129 131 ··· 171 173 verificationCode, 172 174 }) 173 175 const account = agentToSessionAccountOrThrow(agent) 174 - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 176 + const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 175 177 const moderation = configureModerationForAccount(agent, account) 176 178 177 179 const createdAt = new Date().toISOString() ··· 322 324 return undefined 323 325 } 324 326 return { 325 - service: agent.service.toString(), 327 + service: agent.serviceUrl.toString(), 326 328 did: agent.session.did, 327 329 handle: agent.session.handle, 328 330 email: agent.session.email, ··· 332 334 accessJwt: agent.session.accessJwt, 333 335 signupQueued: isSignupQueued(agent.session.accessJwt), 334 336 active: agent.session.active, 335 - status: agent.session.status as SessionAccount['status'], 337 + status: agent.session.status, 336 338 pdsUrl: agent.pdsUrl?.toString(), 337 339 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), 338 340 }
+38 -14
src/state/session/index.tsx
··· 4 4 import * as persisted from '#/state/persisted' 5 5 import {useCloseAllActiveElements} from '#/state/util' 6 6 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 7 + import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' 7 8 import {IS_WEB} from '#/env' 8 9 import {emitSessionDropped} from '../events' 9 10 import { ··· 15 16 sessionAccountToSession, 16 17 } from './agent' 17 18 import {type Action, getInitialState, reducer, type State} from './reducer' 18 - 19 19 export {isSignupQueued} from './util' 20 20 import {addSessionDebugLog} from './logging' 21 21 export type {SessionAccount} from '#/state/session/types' 22 - import {logger} from '#/logger' 23 22 import { 24 23 type SessionApiContext, 25 24 type SessionStateContext, ··· 93 92 } 94 93 95 94 export function Provider({children}: React.PropsWithChildren<{}>) { 95 + const ax = useAnalyticsBase() 96 96 const cancelPendingTask = useOneTaskAtATime() 97 97 const [store] = React.useState(() => new SessionStore()) 98 98 const state = React.useSyncExternalStore(store.subscribe, store.getState) ··· 119 119 async (params, metrics) => { 120 120 addSessionDebugLog({type: 'method:start', method: 'createAccount'}) 121 121 const signal = cancelPendingTask() 122 - logger.metric('account:create:begin', {}, {statsig: true}) 122 + ax.metric('account:create:begin', {}) 123 123 const {agent, account} = await createAgentAndCreateAccount( 124 124 params, 125 125 onAgentSessionChange, ··· 133 133 newAgent: agent, 134 134 newAccount: account, 135 135 }) 136 - logger.metric('account:create:success', metrics, {statsig: true}) 136 + ax.metric('account:create:success', metrics, { 137 + session: utils.accountToSessionMetadata(account), 138 + }) 137 139 addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) 138 140 }, 139 - [store, onAgentSessionChange, cancelPendingTask], 141 + [ax, store, onAgentSessionChange, cancelPendingTask], 140 142 ) 141 143 142 144 const login = React.useCallback<SessionApiContext['login']>( ··· 156 158 newAgent: agent, 157 159 newAccount: account, 158 160 }) 159 - logger.metric( 161 + ax.metric( 160 162 'account:loggedIn', 161 163 {logContext, withPassword: true}, 162 - {statsig: true}, 164 + {session: utils.accountToSessionMetadata(account)}, 163 165 ) 164 166 addSessionDebugLog({type: 'method:end', method: 'login', account}) 165 167 }, 166 - [store, onAgentSessionChange, cancelPendingTask], 168 + [ax, store, onAgentSessionChange, cancelPendingTask], 167 169 ) 168 170 169 171 const logoutCurrentAccount = React.useCallback< ··· 176 178 store.dispatch({ 177 179 type: 'logged-out-current-account', 178 180 }) 179 - logger.metric( 181 + ax.metric( 180 182 'account:loggedOut', 181 183 {logContext, scope: 'current'}, 182 - {statsig: true}, 184 + { 185 + session: utils.accountToSessionMetadata( 186 + prevState.accounts.find( 187 + a => a.did === prevState.currentAgentState.did, 188 + ), 189 + ), 190 + }, 183 191 ) 184 192 addSessionDebugLog({type: 'method:end', method: 'logout'}) 185 193 if (prevState.currentAgentState.did) { ··· 188 196 // reset onboarding flow on logout 189 197 onboardingDispatch({type: 'skip'}) 190 198 }, 191 - [store, cancelPendingTask, onboardingDispatch], 199 + [ax, store, cancelPendingTask, onboardingDispatch], 192 200 ) 193 201 194 202 const logoutEveryAccount = React.useCallback< ··· 197 205 logContext => { 198 206 addSessionDebugLog({type: 'method:start', method: 'logout'}) 199 207 cancelPendingTask() 208 + const prevState = store.getState() 200 209 store.dispatch({ 201 210 type: 'logged-out-every-account', 202 211 }) 203 - logger.metric( 212 + ax.metric( 204 213 'account:loggedOut', 205 214 {logContext, scope: 'every'}, 206 - {statsig: true}, 215 + { 216 + session: utils.accountToSessionMetadata( 217 + prevState.accounts.find( 218 + a => a.did === prevState.currentAgentState.did, 219 + ), 220 + ), 221 + }, 207 222 ) 208 223 addSessionDebugLog({type: 'method:end', method: 'logout'}) 209 224 clearAgeAssuranceData() ··· 359 374 return ( 360 375 <AgentContext.Provider value={agent}> 361 376 <StateContext.Provider value={stateContext}> 362 - <ApiContext.Provider value={api}>{children}</ApiContext.Provider> 377 + <ApiContext.Provider value={api}> 378 + <AnalyticsContext 379 + metadata={utils.useMeta({ 380 + session: utils.accountToSessionMetadata( 381 + stateContext.currentAccount, 382 + ), 383 + })}> 384 + {children} 385 + </AnalyticsContext> 386 + </ApiContext.Provider> 363 387 </StateContext.Provider> 364 388 </AgentContext.Provider> 365 389 )
+5 -5
src/state/session/types.ts
··· 1 - import {type LogEvents} from '#/lib/statsig/statsig' 2 1 import {type PersistedAccount} from '#/state/persisted' 2 + import {type Metrics} from '#/analytics/metrics' 3 3 4 4 export type SessionAccount = PersistedAccount 5 5 ··· 21 21 verificationPhone?: string 22 22 verificationCode?: string 23 23 }, 24 - metrics: LogEvents['account:create:success'], 24 + metrics: Metrics['account:create:success'], 25 25 ) => Promise<void> 26 26 login: ( 27 27 props: { ··· 30 30 password: string 31 31 authFactorToken?: string | undefined 32 32 }, 33 - logContext: LogEvents['account:loggedIn']['logContext'], 33 + logContext: Metrics['account:loggedIn']['logContext'], 34 34 ) => Promise<void> 35 35 logoutCurrentAccount: ( 36 - logContext: LogEvents['account:loggedOut']['logContext'], 36 + logContext: Metrics['account:loggedOut']['logContext'], 37 37 ) => void 38 38 logoutEveryAccount: ( 39 - logContext: LogEvents['account:loggedOut']['logContext'], 39 + logContext: Metrics['account:loggedOut']['logContext'], 40 40 ) => void 41 41 resumeSession: ( 42 42 account: SessionAccount,
+4 -3
src/state/shell/progress-guide.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 5 import { 7 6 ProgressGuideToast, 8 7 type ProgressGuideToastRef, 9 8 } from '#/components/ProgressGuide/Toast' 9 + import {useAnalytics} from '#/analytics' 10 10 import { 11 11 usePreferencesQuery, 12 12 useSetActiveProgressGuideMutation, ··· 71 71 } 72 72 73 73 export function Provider({children}: React.PropsWithChildren<{}>) { 74 + const ax = useAnalytics() 74 75 const {_} = useLingui() 75 76 const {data: preferences} = usePreferencesQuery() 76 77 const {mutateAsync, variables, isPending} = ··· 140 141 endProgressGuide() { 141 142 setLocalGuideState(undefined) 142 143 mutateAsync(undefined) 143 - logEvent('progressGuide:hide', {}) 144 + ax.metric('progressGuide:hide', {}) 144 145 }, 145 146 146 147 captureAction(action: ProgressGuideAction, count = 1) { ··· 202 203 mutateAsync(guide?.isComplete ? undefined : guide) 203 204 }, 204 205 } 205 - }, [activeProgressGuide, mutateAsync, setLocalGuideState]) 206 + }, [ax, activeProgressGuide, mutateAsync, setLocalGuideState]) 206 207 207 208 return ( 208 209 <ProgressGuideContext.Provider value={localGuideState}>
+11
src/storage/schema.ts
··· 5 5 * Device data that's specific to the device and does not vary based account 6 6 */ 7 7 export type Device = { 8 + /** 9 + * Formerly managed by StatSig, this is the migrated stable ID for the 10 + * device, used with our logging and metrics tracking. 11 + */ 12 + deviceId?: string 13 + /** 14 + * Session ID storage for _native only_. On web, use we `sessionStorage` 15 + */ 16 + nativeSessionId?: string 17 + nativeSessionIdLastEventAt?: number 18 + 8 19 fontScale: '-2' | '-1' | '0' | '1' | '2' 9 20 fontFamily: 'system' | 'theme' 10 21 lastNuxDialog: string | undefined
+4 -3
src/view/com/auth/LoggedOut.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {PressableScale} from '#/lib/custom-animations/PressableScale' 8 - import {logEvent} from '#/lib/statsig/statsig' 9 8 import { 10 9 useLoggedOutView, 11 10 useLoggedOutViewControls, ··· 18 17 import {atoms as a, native, tokens, useTheme} from '#/alf' 19 18 import {Button, ButtonIcon} from '#/components/Button' 20 19 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 20 + import {useAnalytics} from '#/analytics' 21 21 import {SplashScreen} from './SplashScreen' 22 22 23 23 enum ScreenState { ··· 30 30 31 31 export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { 32 32 const {_} = useLingui() 33 + const ax = useAnalytics() 33 34 const t = useTheme() 34 35 const insets = useSafeAreaInsets() 35 36 const setMinimalShellMode = useSetMinimalShellMode() ··· 94 95 <SplashScreen 95 96 onPressSignin={() => { 96 97 setScreenState(ScreenState.S_Login) 97 - logEvent('splash:signInPressed', {}) 98 + ax.metric('splash:signInPressed', {}) 98 99 }} 99 100 onPressCreateAccount={() => { 100 101 setScreenState(ScreenState.S_CreateAccount) 101 - logEvent('splash:createAccountPressed', {}) 102 + ax.metric('splash:createAccountPressed', {}) 102 103 }} 103 104 /> 104 105 ) : undefined}
+6 -4
src/view/com/composer/Composer.tsx
··· 58 58 59 59 import * as apilib from '#/lib/api/index' 60 60 import {EmbeddingDisabledError} from '#/lib/api/resolve' 61 + import {useAppState} from '#/lib/appState' 61 62 import {retry} from '#/lib/async/retry' 62 63 import {until} from '#/lib/async/until' 63 64 import { ··· 65 66 SUPPORTED_MIME_TYPES, 66 67 type SupportedMimeTypes, 67 68 } from '#/lib/constants' 68 - import {useAppState} from '#/lib/hooks/useAppState' 69 69 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 70 70 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 71 71 import {usePalette} from '#/lib/hooks/usePalette' 72 72 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 73 73 import {mimeToExt} from '#/lib/media/video/util' 74 74 import {type NavigationProp} from '#/lib/routes/types' 75 - import {logEvent} from '#/lib/statsig/statsig' 76 75 import {cleanError} from '#/lib/strings/errors' 77 76 import {colors} from '#/lib/styles' 78 77 import {logger} from '#/logger' ··· 129 128 import * as Prompt from '#/components/Prompt' 130 129 import * as Toast from '#/components/Toast' 131 130 import {Text as NewText} from '#/components/Typography' 131 + import {useAnalytics} from '#/analytics' 132 132 import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 133 133 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 134 134 import {PostLanguageSelect} from './select-language/PostLanguageSelect' ··· 178 178 cancelRef?: React.RefObject<CancelRef | null> 179 179 }) => { 180 180 const {currentAccount} = useSession() 181 + const ax = useAnalytics() 181 182 const agent = useAgent() 182 183 const queryClient = useQueryClient() 183 184 const currentDid = currentAccount!.did ··· 520 521 if (postUri) { 521 522 let index = 0 522 523 for (let post of thread.posts) { 523 - logEvent('post:create', { 524 + ax.metric('post:create', { 524 525 imageCount: 525 526 post.embed.media?.type === 'images' 526 527 ? post.embed.media.images.length ··· 536 537 } 537 538 } 538 539 if (thread.posts.length > 1) { 539 - logEvent('thread:create', { 540 + ax.metric('thread:create', { 540 541 postCount: thread.posts.length, 541 542 isReply: !!replyTo, 542 543 }) ··· 594 595 }, 500) 595 596 }, [ 596 597 _, 598 + ax, 597 599 agent, 598 600 thread, 599 601 canPost,
+4 -3
src/view/com/composer/photos/SelectGifBtn.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 6 import {type Gif} from '#/state/queries/tenor' 8 7 import {atoms as a, useTheme} from '#/alf' 9 8 import {Button} from '#/components/Button' 10 9 import {GifSelectDialog} from '#/components/dialogs/GifSelect' 11 10 import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' 11 + import {useAnalytics} from '#/analytics' 12 12 13 13 type Props = { 14 14 onClose?: () => void ··· 17 17 } 18 18 19 19 export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { 20 + const ax = useAnalytics() 20 21 const {_} = useLingui() 21 22 const ref = useRef<{open: () => void}>(null) 22 23 const t = useTheme() 23 24 24 25 const onPressSelectGif = useCallback(async () => { 25 - logEvent('composer:gif:open', {}) 26 + ax.metric('composer:gif:open', {}) 26 27 Keyboard.dismiss() 27 28 ref.current?.open() 28 - }, []) 29 + }, [ax]) 29 30 30 31 return ( 31 32 <>
+3 -1
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 24 24 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 25 25 import * as Tooltip from '#/components/Tooltip' 26 26 import {Text} from '#/components/Typography' 27 + import {useAnalytics} from '#/analytics' 27 28 import {IS_NATIVE} from '#/env' 28 29 import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' 29 30 ··· 42 43 style?: StyleProp<AnimatedStyle<ViewStyle>> 43 44 }) { 44 45 const {_} = useLingui() 46 + const ax = useAnalytics() 45 47 const control = Dialog.useDialogControl() 46 48 const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() 47 49 const [showTooltip, setShowTooltip] = useState(false) ··· 66 68 const [persist, setPersist] = useState(false) 67 69 68 70 const onPress = () => { 69 - logger.metric('composer:threadgate:open', { 71 + ax.metric('composer:threadgate:open', { 70 72 nudged: tooltipWasShown, 71 73 }) 72 74
+11 -9
src/view/com/feeds/ComposerPrompt.tsx
··· 10 10 useVideoLibraryPermission, 11 11 } from '#/lib/hooks/usePermissions' 12 12 import {openCamera, openUnifiedPicker} from '#/lib/media/picker' 13 - import {logger} from '#/logger' 14 13 import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 15 14 import {MAX_IMAGES} from '#/view/com/composer/state/composer' 16 15 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 21 20 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 22 21 import {SubtleHover} from '#/components/SubtleHover' 23 22 import {Text} from '#/components/Typography' 23 + import {useAnalytics} from '#/analytics' 24 24 import {IS_NATIVE} from '#/env' 25 25 26 26 export function ComposerPrompt() { 27 - const {_} = useLingui() 28 27 const t = useTheme() 28 + const ax = useAnalytics() 29 + const {_} = useLingui() 29 30 const {openComposer} = useOpenComposer() 30 31 const profile = useCurrentAccountProfile() 31 32 const [hover, setHover] = useState(false) ··· 35 36 const sheetWrapper = useSheetWrapper() 36 37 37 38 const onPress = useCallback(() => { 38 - logger.metric('composerPrompt:press', {}) 39 + ax.metric('composerPrompt:press', {}) 39 40 openComposer({}) 40 - }, [openComposer]) 41 + }, [ax, openComposer]) 41 42 42 43 const onPressImage = useCallback(async () => { 43 - logger.metric('composerPrompt:gallery:press', {}) 44 + ax.metric('composerPrompt:gallery:press', {}) 44 45 45 46 // On web, open the composer with the gallery picker auto-opening 46 47 if (!IS_NATIVE) { ··· 87 88 } 88 89 } catch (err: any) { 89 90 if (!String(err).toLowerCase().includes('cancel')) { 90 - logger.warn('Error opening image picker', {error: err}) 91 + ax.logger.error('Error opening image picker', {error: err}) 91 92 } 92 93 } 93 94 }, [ 95 + ax, 94 96 openComposer, 95 97 requestPhotoAccessIfNeeded, 96 98 requestVideoAccessIfNeeded, ··· 98 100 ]) 99 101 100 102 const onPressCamera = useCallback(async () => { 101 - logger.metric('composerPrompt:camera:press', {}) 103 + ax.metric('composerPrompt:camera:press', {}) 102 104 103 105 try { 104 106 if (!(await requestCameraAccessIfNeeded())) { ··· 126 128 }) 127 129 } catch (err: any) { 128 130 if (!String(err).toLowerCase().includes('cancel')) { 129 - logger.warn('Error opening camera', {error: err}) 131 + ax.logger.error('Error opening camera', {error: err}) 130 132 } 131 133 } 132 - }, [openComposer, requestCameraAccessIfNeeded]) 134 + }, [ax, openComposer, requestCameraAccessIfNeeded]) 133 135 134 136 if (!profile) { 135 137 return null
+6 -5
src/view/com/feeds/FeedPage.tsx
··· 18 18 import {ComposeIcon2} from '#/lib/icons' 19 19 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' 20 20 import {type AllNavigatorParams} from '#/lib/routes/types' 21 - import {logEvent} from '#/lib/statsig/statsig' 22 21 import {s} from '#/lib/styles' 23 22 import {listenSoftReset} from '#/state/events' 24 23 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' ··· 33 32 import {useSession} from '#/state/session' 34 33 import {useSetMinimalShellMode} from '#/state/shell' 35 34 import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 35 + import {useAnalytics} from '#/analytics' 36 36 import {IS_NATIVE} from '#/env' 37 37 import {PostFeed} from '../posts/PostFeed' 38 38 import {FAB} from '../util/fab/FAB' ··· 63 63 savedFeedConfig?: AppBskyActorDefs.SavedFeed 64 64 feedInfo: FeedSourceInfo 65 65 }) { 66 + const ax = useAnalytics() 66 67 const {hasSession} = useSession() 67 68 const {_} = useLingui() 68 69 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() ··· 105 106 scrollToTop() 106 107 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 107 108 setHasNew(false) 108 - logEvent('feed:refresh', { 109 + ax.metric('feed:refresh', { 109 110 feedType: feed.split('|')[0], 110 111 feedUrl: feed, 111 112 reason: 'soft-reset', 112 113 }) 113 114 } 114 - }, [navigation, isPageFocused, scrollToTop, queryClient, feed]) 115 + }, [ax, navigation, isPageFocused, scrollToTop, queryClient, feed]) 115 116 116 117 // fires when page within screen is activated/deactivated 117 118 useEffect(() => { ··· 129 130 scrollToTop() 130 131 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 131 132 setHasNew(false) 132 - logEvent('feed:refresh', { 133 + ax.metric('feed:refresh', { 133 134 feedType: feed.split('|')[0], 134 135 feedUrl: feed, 135 136 reason: 'load-latest', 136 137 }) 137 - }, [scrollToTop, feed, queryClient]) 138 + }, [ax, scrollToTop, feed, queryClient]) 138 139 139 140 const shouldPrefetch = IS_NATIVE && isPageAdjacent 140 141 const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
+3 -2
src/view/com/posts/CustomFeedEmptyState.tsx
··· 12 12 import {MagnifyingGlassIcon} from '#/lib/icons' 13 13 import {type NavigationProp} from '#/lib/routes/types' 14 14 import {s} from '#/lib/styles' 15 - import {logger} from '#/logger' 16 15 import {useFeedFeedbackContext} from '#/state/feed-feedback' 17 16 import {useSession} from '#/state/session' 17 + import {useAnalytics} from '#/analytics' 18 18 import {IS_WEB} from '#/env' 19 19 import {Button} from '../util/forms/Button' 20 20 import {Text} from '../util/text/Text' 21 21 22 22 export function CustomFeedEmptyState() { 23 + const ax = useAnalytics() 23 24 const feedFeedback = useFeedFeedbackContext() 24 25 const {currentAccount} = useSession() 25 26 const hasLoggedDiscoverEmptyErrorRef = React.useRef(false) ··· 33 34 !hasLoggedDiscoverEmptyErrorRef.current 34 35 ) { 35 36 hasLoggedDiscoverEmptyErrorRef.current = true 36 - logger.metric('feed:discover:emptyError', { 37 + ax.metric('feed:discover:emptyError', { 37 38 userDid: currentAccount.did, 38 39 }) 39 40 }
+24 -34
src/view/com/posts/PostFeed.tsx
··· 31 31 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 32 32 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 33 33 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 34 - import {logEvent} from '#/lib/statsig/statsig' 35 34 import {isNetworkError} from '#/lib/strings/errors' 36 35 import {logger} from '#/logger' 37 36 import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' ··· 69 68 } from '#/components/feeds/PostFeedVideoGridRow' 70 69 import {TrendingInterstitial} from '#/components/interstitials/Trending' 71 70 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 71 + import {useAnalytics} from '#/analytics' 72 72 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 73 73 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 74 74 import {ComposerPrompt} from '../feeds/ComposerPrompt' ··· 232 232 initialNumToRender?: number 233 233 isVideoFeed?: boolean 234 234 }): React.ReactNode => { 235 + const ax = useAnalytics() 235 236 const {_} = useLingui() 236 237 const queryClient = useQueryClient() 237 238 const {currentAccount, hasSession} = useSession() ··· 685 686 // = 686 687 687 688 const onRefresh = useCallback(async () => { 688 - logEvent('feed:refresh', { 689 + ax.metric('feed:refresh', { 689 690 feedType: feedType, 690 691 feedUrl: feed, 691 692 reason: 'pull-to-refresh', ··· 698 699 logger.error('Failed to refresh posts feed', {message: err}) 699 700 } 700 701 setIsPTRing(false) 701 - }, [refetch, setIsPTRing, onHasNew, feed, feedType]) 702 + }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) 702 703 703 704 const onEndReached = useCallback(async () => { 704 705 if (isFetching || !hasNextPage || isError) return 705 706 706 - logEvent('feed:endReached', { 707 + ax.metric('feed:endReached', { 707 708 feedType: feedType, 708 709 feedUrl: feed, 709 710 itemCount: feedItems.length, ··· 714 715 logger.error('Failed to load more posts', {message: err}) 715 716 } 716 717 }, [ 718 + ax, 717 719 isFetching, 718 720 hasNextPage, 719 721 isError, ··· 933 935 934 936 const position = getPostPosition('sliceItem', item.key) 935 937 936 - logger.metric( 937 - 'post:view', 938 - { 939 - uri: post.uri, 940 - authorDid: post.author.did, 941 - logContext: 'FeedItem', 942 - feedDescriptor: feedFeedback.feedDescriptor || feed, 943 - position, 944 - }, 945 - {statsig: false}, 946 - ) 938 + ax.metric('post:view', { 939 + uri: post.uri, 940 + authorDid: post.author.did, 941 + logContext: 'FeedItem', 942 + feedDescriptor: feedFeedback.feedDescriptor || feed, 943 + position, 944 + }) 947 945 } 948 946 949 947 // Live status tracking (existing code) ··· 955 953 ) { 956 954 if (!seenActorWithStatusRef.current.has(actor.did)) { 957 955 seenActorWithStatusRef.current.add(actor.did) 958 - logger.metric( 959 - 'live:view:post', 960 - { 961 - subject: actor.did, 962 - feed, 963 - }, 964 - {statsig: false}, 965 - ) 956 + ax.metric('live:view:post', { 957 + subject: actor.did, 958 + feed, 959 + }) 966 960 } 967 961 } 968 962 } else if (item.type === 'videoGridRow') { ··· 976 970 977 971 const position = getPostPosition('videoGridRow', item.key) 978 972 979 - logger.metric( 980 - 'post:view', 981 - { 982 - uri: post.uri, 983 - authorDid: post.author.did, 984 - logContext: 'FeedItem', 985 - feedDescriptor: feedFeedback.feedDescriptor || feed, 986 - position, 987 - }, 988 - {statsig: false}, 989 - ) 973 + ax.metric('post:view', { 974 + uri: post.uri, 975 + authorDid: post.author.did, 976 + logContext: 'FeedItem', 977 + feedDescriptor: feedFeedback.feedDescriptor || feed, 978 + position, 979 + }) 990 980 } 991 981 } 992 982 }
+5 -4
src/view/com/posts/PostFeedItem.tsx
··· 18 18 import {usePalette} from '#/lib/hooks/usePalette' 19 19 import {makeProfileLink} from '#/lib/routes/links' 20 20 import {countLines} from '#/lib/strings/helpers' 21 - import {logger} from '#/logger' 22 21 import { 23 22 POST_TOMBSTONE, 24 23 type Shadow, ··· 48 47 import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 49 48 import {RichText} from '#/components/RichText' 50 49 import {SubtleHover} from '#/components/SubtleHover' 50 + import {useAnalytics} from '#/analytics' 51 51 import * as bsky from '#/types/bsky' 52 52 import {PostFeedReason} from './PostFeedReason' 53 53 ··· 158 158 rootPost: AppBskyFeedDefs.PostView 159 159 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 160 160 }): React.ReactNode => { 161 + const ax = useAnalytics() 161 162 const queryClient = useQueryClient() 162 163 const {openComposer} = useOpenComposer() 163 164 const pal = usePalette('default') ··· 198 199 feedContext, 199 200 reqId, 200 201 }) 201 - logger.metric('post:clickthroughAuthor', { 202 + ax.metric('post:clickthroughAuthor', { 202 203 uri: post.uri, 203 204 authorDid: post.author.did, 204 205 logContext: 'FeedItem', ··· 222 223 feedContext, 223 224 reqId, 224 225 }) 225 - logger.metric('post:clickthroughEmbed', { 226 + ax.metric('post:clickthroughEmbed', { 226 227 uri: post.uri, 227 228 authorDid: post.author.did, 228 229 logContext: 'FeedItem', ··· 237 238 feedContext, 238 239 reqId, 239 240 }) 240 - logger.metric('post:clickthroughItem', { 241 + ax.metric('post:clickthroughItem', { 241 242 uri: post.uri, 242 243 authorDid: post.author.did, 243 244 logContext: 'FeedItem',
+12 -14
src/view/com/profile/ProfileFollowers.tsx
··· 12 12 import {useSession} from '#/state/session' 13 13 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 14 14 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 15 + import {useAnalytics} from '#/analytics' 15 16 import {List} from '../util/List' 16 17 import {ProfileCardWithFollowBtn} from './ProfileCard' 17 18 ··· 41 42 42 43 export function ProfileFollowers({name}: {name: string}) { 43 44 const {_} = useLingui() 45 + const ax = useAnalytics() 44 46 const navigation = useNavigation() 45 47 const initialNumToRender = useInitialNumToRender() 46 48 const {currentAccount} = useSession() ··· 88 90 currentPageCount >= 3 && 89 91 currentPageCount > paginationTrackingRef.current.page 90 92 ) { 91 - logger.metric('profile:followers:paginate', { 93 + ax.metric('profile:followers:paginate', { 92 94 contextProfileDid: resolvedDid, 93 95 itemCount: followers.length, 94 96 page: currentPageCount, 95 97 }) 96 98 } 97 99 paginationTrackingRef.current.page = currentPageCount 98 - }, [data?.pages?.length, resolvedDid, followers.length]) 100 + }, [ax, data?.pages?.length, resolvedDid, followers.length]) 99 101 100 102 const onRefresh = React.useCallback(async () => { 101 103 setIsPTRing(true) ··· 125 127 // track pageview 126 128 React.useEffect(() => { 127 129 if (resolvedDid) { 128 - logger.metric('profile:followers:view', { 130 + ax.metric('profile:followers:view', { 129 131 contextProfileDid: resolvedDid, 130 132 isOwnProfile: isMe, 131 133 }) 132 134 } 133 - }, [resolvedDid, isMe]) 135 + }, [ax, resolvedDid, isMe]) 134 136 135 137 // track seen items 136 138 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 147 149 if (position === 0) { 148 150 return 149 151 } 150 - logger.metric( 151 - 'profileCard:seen', 152 - { 153 - profileDid: item.did, 154 - position, 155 - ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 156 - }, 157 - {statsig: false}, 158 - ) 152 + ax.metric('profileCard:seen', { 153 + profileDid: item.did, 154 + position, 155 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 156 + }) 159 157 }, 160 - [followers, resolvedDid], 158 + [ax, followers, resolvedDid], 161 159 ) 162 160 163 161 if (followers.length < 1) {
+12 -14
src/view/com/profile/ProfileFollows.tsx
··· 14 14 import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX' 15 15 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 16 16 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 17 + import {useAnalytics} from '#/analytics' 17 18 import {IS_WEB} from '#/env' 18 19 import {List} from '../util/List' 19 20 import {ProfileCardWithFollowBtn} from './ProfileCard' ··· 44 45 45 46 export function ProfileFollows({name}: {name: string}) { 46 47 const {_} = useLingui() 48 + const ax = useAnalytics() 47 49 const initialNumToRender = useInitialNumToRender() 48 50 const {currentAccount} = useSession() 49 51 const navigation = useNavigation<NavigationProp>() ··· 100 102 currentPageCount >= 3 && 101 103 currentPageCount > paginationTrackingRef.current.page 102 104 ) { 103 - logger.metric('profile:following:paginate', { 105 + ax.metric('profile:following:paginate', { 104 106 contextProfileDid: resolvedDid, 105 107 itemCount: follows.length, 106 108 page: currentPageCount, 107 109 }) 108 110 } 109 111 paginationTrackingRef.current.page = currentPageCount 110 - }, [data?.pages?.length, resolvedDid, follows.length]) 112 + }, [ax, data?.pages?.length, resolvedDid, follows.length]) 111 113 112 114 const onRefresh = React.useCallback(async () => { 113 115 setIsPTRing(true) ··· 137 139 // track pageview 138 140 React.useEffect(() => { 139 141 if (resolvedDid) { 140 - logger.metric('profile:following:view', { 142 + ax.metric('profile:following:view', { 141 143 contextProfileDid: resolvedDid, 142 144 isOwnProfile: isMe, 143 145 }) 144 146 } 145 - }, [resolvedDid, isMe]) 147 + }, [ax, resolvedDid, isMe]) 146 148 147 149 // track seen items 148 150 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 159 161 if (position === 0) { 160 162 return 161 163 } 162 - logger.metric( 163 - 'profileCard:seen', 164 - { 165 - profileDid: item.did, 166 - position, 167 - ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 168 - }, 169 - {statsig: false}, 170 - ) 164 + ax.metric('profileCard:seen', { 165 + profileDid: item.did, 166 + position, 167 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 168 + }) 171 169 }, 172 - [follows, resolvedDid], 170 + [ax, follows, resolvedDid], 173 171 ) 174 172 175 173 if (follows.length < 1) {
+13 -12
src/view/com/profile/ProfileMenu.tsx
··· 11 11 import {type NavigationProp} from '#/lib/routes/types' 12 12 import {shareText, shareUrl} from '#/lib/sharing' 13 13 import {toShareUrl} from '#/lib/strings/url-helpers' 14 - import {logger} from '#/logger' 15 14 import {type Shadow} from '#/state/cache/types' 16 15 import {useModalControls} from '#/state/modals' 17 16 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 60 59 import {useFullVerificationState} from '#/components/verification' 61 60 import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 62 61 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 62 + import {useAnalytics} from '#/analytics' 63 63 import {IS_WEB} from '#/env' 64 64 import {Dot} from '#/features/nuxs/components/Dot' 65 65 import {Gradient} from '#/features/nuxs/components/Gradient' ··· 71 71 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 72 72 }): React.ReactNode => { 73 73 const t = useTheme() 74 + const ax = useAnalytics() 74 75 const {_} = useLingui() 75 76 const {currentAccount, hasSession} = useSession() 76 77 const {openModal} = useModalControls() ··· 121 122 }, [queryClient, profile.did]) 122 123 123 124 const onPressAddToStarterPacks = React.useCallback(() => { 124 - logger.metric('profile:addToStarterPack', {}) 125 + ax.metric('profile:addToStarterPack', {}) 125 126 addToStarterPacksDialogControl.open() 126 127 }, [addToStarterPacksDialogControl]) 127 128 ··· 147 148 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 148 149 } catch (e: any) { 149 150 if (e?.name !== 'AbortError') { 150 - logger.error('Failed to unmute account', {message: e}) 151 + ax.logger.error('Failed to unmute account', {message: e}) 151 152 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 152 153 } 153 154 } ··· 157 158 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 158 159 } catch (e: any) { 159 160 if (e?.name !== 'AbortError') { 160 - logger.error('Failed to mute account', {message: e}) 161 + ax.logger.error('Failed to mute account', {message: e}) 161 162 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 162 163 } 163 164 } 164 165 } 165 - }, [profile.viewer?.muted, queueUnmute, _, queueMute]) 166 + }, [ax, profile.viewer?.muted, queueUnmute, _, queueMute]) 166 167 167 168 const blockAccount = React.useCallback(async () => { 168 169 if (profile.viewer?.blocking) { ··· 171 172 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 172 173 } catch (e: any) { 173 174 if (e?.name !== 'AbortError') { 174 - logger.error('Failed to unblock account', {message: e}) 175 + ax.logger.error('Failed to unblock account', {message: e}) 175 176 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 176 177 } 177 178 } ··· 181 182 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 182 183 } catch (e: any) { 183 184 if (e?.name !== 'AbortError') { 184 - logger.error('Failed to block account', {message: e}) 185 + ax.logger.error('Failed to block account', {message: e}) 185 186 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 186 187 } 187 188 } 188 189 } 189 - }, [profile.viewer?.blocking, _, queueUnblock, queueBlock]) 190 + }, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock]) 190 191 191 192 const onPressFollowAccount = React.useCallback(async () => { 192 193 try { ··· 194 195 Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) 195 196 } catch (e: any) { 196 197 if (e?.name !== 'AbortError') { 197 - logger.error('Failed to follow account', {message: e}) 198 + ax.logger.error('Failed to follow account', {message: e}) 198 199 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 199 200 } 200 201 } 201 - }, [_, queueFollow]) 202 + }, [_, ax, queueFollow]) 202 203 203 204 const onPressUnfollowAccount = React.useCallback(async () => { 204 205 try { ··· 206 207 Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) 207 208 } catch (e: any) { 208 209 if (e?.name !== 'AbortError') { 209 - logger.error('Failed to unfollow account', {message: e}) 210 + ax.logger.error('Failed to unfollow account', {message: e}) 210 211 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 211 212 } 212 213 } 213 - }, [_, queueUnfollow]) 214 + }, [_, ax, queueUnfollow]) 214 215 215 216 const onPressReportAccount = React.useCallback(() => { 216 217 reportDialogControl.open()
+3 -5
src/view/com/util/UserAvatar.tsx
··· 52 52 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 53 53 import * as Menu from '#/components/Menu' 54 54 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55 + import {useAnalytics} from '#/analytics' 55 56 import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 56 57 import type * as bsky from '#/types/bsky' 57 58 ··· 530 531 live, 531 532 ...props 532 533 }: PreviewableUserAvatarProps): React.ReactNode => { 534 + const ax = useAnalytics() 533 535 const {_} = useLingui() 534 536 const queryClient = useQueryClient() 535 537 const status = useActorStatus(profile) ··· 543 545 544 546 const onOpenLiveStatus = useCallback(() => { 545 547 playHaptic('Light') 546 - logger.metric( 547 - 'live:card:open', 548 - {subject: profile.did, from: 'post'}, 549 - {statsig: true}, 550 - ) 548 + ax.metric('live:card:open', {subject: profile.did, from: 'post'}) 551 549 liveControl.open() 552 550 }, [liveControl, playHaptic, profile.did]) 553 551
+5 -4
src/view/screens/Home.tsx
··· 11 11 type HomeTabNavigatorParams, 12 12 type NativeStackScreenProps, 13 13 } from '#/lib/routes/types' 14 - import {logEvent} from '#/lib/statsig/statsig' 15 14 import {emitSoftReset} from '#/state/events' 16 15 import { 17 16 type SavedFeedSourceInfo, ··· 36 35 import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' 37 36 import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' 38 37 import * as Layout from '#/components/Layout' 38 + import {useAnalytics} from '#/analytics' 39 39 import {IS_WEB} from '#/env' 40 40 import {useDemoMode} from '#/storage/hooks/demo-mode' 41 41 ··· 105 105 preferences: UsePreferencesQueryResponse 106 106 pinnedFeedInfos: SavedFeedSourceInfo[] 107 107 }) { 108 + const ax = useAnalytics() 108 109 const allFeeds = React.useMemo( 109 110 () => pinnedFeedInfos.map(f => f.feedDescriptor), 110 111 [pinnedFeedInfos], ··· 147 148 useFocusEffect( 148 149 useNonReactiveCallback(() => { 149 150 if (maybeSelectedFeed) { 150 - logEvent('home:feedDisplayed', { 151 + ax.metric('home:feedDisplayed', { 151 152 index: selectedIndex, 152 153 feedType: maybeSelectedFeed.split('|')[0], 153 154 feedUrl: maybeSelectedFeed, ··· 168 169 setSelectedFeed(maybeFeed) 169 170 170 171 if (maybeFeed) { 171 - logEvent('home:feedDisplayed', { 172 + ax.metric('home:feedDisplayed', { 172 173 index, 173 174 feedType: maybeFeed.split('|')[0], 174 175 feedUrl: maybeFeed, 175 176 }) 176 177 } 177 178 }, 178 - [setSelectedFeed, setMinimalShellMode, allFeeds], 179 + [ax, setSelectedFeed, setMinimalShellMode, allFeeds], 179 180 ) 180 181 181 182 const onPressSelected = React.useCallback(() => {
+6 -9
src/view/shell/desktop/Feeds.tsx
··· 5 5 6 6 import {getCurrentRoute} from '#/lib/routes/helpers' 7 7 import {type NavigationProp} from '#/lib/routes/types' 8 - import {logger} from '#/logger' 9 8 import {emitSoftReset} from '#/state/events' 10 9 import { 11 10 type SavedFeedSourceInfo, ··· 19 18 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 20 19 import {Link} from '#/components/Link' 21 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 22 23 23 export function DesktopFeeds() { 24 24 const t = useTheme() 25 25 const {_} = useLingui() 26 + const ax = useAnalytics() 26 27 const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos() 27 28 const selectedFeed = useSelectedFeed() 28 29 const setSelectedFeed = useSetSelectedFeed() ··· 86 87 feedInfo={feedInfo} 87 88 current={current} 88 89 onPress={() => { 89 - logger.metric( 90 - 'desktopFeeds:feed:click', 91 - { 92 - feedUri: feedInfo.uri, 93 - feedDescriptor: feed, 94 - }, 95 - {statsig: false}, 96 - ) 90 + ax.metric('desktopFeeds:feed:click', { 91 + feedUri: feedInfo.uri, 92 + feedDescriptor: feed, 93 + }) 97 94 setSelectedFeed(feed) 98 95 navigation.navigate('Home') 99 96 if (route.name === 'Home' && feed === selectedFeed) {
+4 -3
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logger} from '#/logger' 6 5 import { 7 6 useTrendingSettings, 8 7 useTrendingSettingsApi, ··· 16 15 import * as Prompt from '#/components/Prompt' 17 16 import {TrendingTopicLink} from '#/components/TrendingTopics' 18 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 19 20 20 const TRENDING_LIMIT = 5 21 21 ··· 28 28 function Inner() { 29 29 const t = useTheme() 30 30 const {_} = useLingui() 31 + const ax = useAnalytics() 31 32 const trendingPrompt = Prompt.usePromptControl() 32 33 const {setTrendingDisabled} = useTrendingSettingsApi() 33 34 const {data: trending, error, isLoading} = useTrendingTopics() 34 35 const noTopics = !isLoading && !error && !trending?.topics?.length 35 36 36 37 const onConfirmHide = () => { 37 - logger.metric('trendingTopics:hide', {context: 'sidebar'}) 38 + ax.metric('trendingTopics:hide', {context: 'sidebar'}) 38 39 setTrendingDisabled(true) 39 40 } 40 41 ··· 90 91 topic={topic} 91 92 style={[a.self_start]} 92 93 onPress={() => { 93 - logger.metric('trendingTopic:click', {context: 'sidebar'}) 94 + ax.metric('trendingTopic:click', {context: 'sidebar'}) 94 95 }}> 95 96 {({hovered}) => ( 96 97 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+3
src/view/shell/index.tsx
··· 42 42 import {useAgeAssurance} from '#/ageAssurance' 43 43 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 44 44 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 45 + import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' 45 46 import {IS_ANDROID, IS_IOS} from '#/env' 46 47 import {RoutesContainer, TabsNavigator} from '#/Navigation' 47 48 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' ··· 245 246 <RedirectOverlay /> 246 247 </> 247 248 )} 249 + 250 + <PassiveAnalytics /> 248 251 </View> 249 252 ) 250 253 }
+3
src/view/shell/index.web.tsx
··· 34 34 import {useAgeAssurance} from '#/ageAssurance' 35 35 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 36 36 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 37 + import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' 37 38 import {FlatNavigator, RoutesContainer} from '#/Navigation' 38 39 import {Composer} from './Composer.web' 39 40 import {DrawerContent} from './Drawer' ··· 181 182 <RedirectOverlay /> 182 183 </> 183 184 )} 185 + 186 + <PassiveAnalytics /> 184 187 </View> 185 188 ) 186 189 }
+22 -38
yarn.lock
··· 4678 4678 dependencies: 4679 4679 nanoid "^3.3.1" 4680 4680 4681 + "@growthbook/growthbook-react@^1.6.2": 4682 + version "1.6.2" 4683 + resolved "https://registry.yarnpkg.com/@growthbook/growthbook-react/-/growthbook-react-1.6.2.tgz#847135be0c46b167f980dbe6015e5f4ed010475c" 4684 + integrity sha512-96Bo2Jwd4NBn/kBLN4ceF299PhTw8fQltRykD32hu2xMW8/LXhB8swxbPshGK+Xfa2gjgt24kpZ5oSvaVLLT7w== 4685 + dependencies: 4686 + "@growthbook/growthbook" "^1.6.2" 4687 + 4688 + "@growthbook/growthbook@^1.6.2": 4689 + version "1.6.2" 4690 + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.2.tgz#6a25122deac8a09955f6bddeb134af62b209809b" 4691 + integrity sha512-x3sK6Lff4BVusIzcdBeHZqA3B4kLs3kM/pJ7vvLTJwb6N/+Yn99EF1yc0XU6cfDVqFq6uFkvIFhMwDWUaKD73g== 4692 + dependencies: 4693 + dom-mutator "^0.6.0" 4694 + 4681 4695 "@grpc/grpc-js@^1.8.20": 4682 4696 version "1.13.3" 4683 4697 resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" ··· 6262 6276 resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" 6263 6277 integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== 6264 6278 6265 - "@react-native-async-storage/async-storage@2.2.0", "@react-native-async-storage/async-storage@^1.15.2": 6279 + "@react-native-async-storage/async-storage@2.2.0": 6266 6280 version "2.2.0" 6267 6281 resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" 6268 6282 integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== ··· 10517 10531 dependencies: 10518 10532 utila "~0.4" 10519 10533 10534 + dom-mutator@^0.6.0: 10535 + version "0.6.0" 10536 + resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5" 10537 + integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg== 10538 + 10520 10539 dom-serializer@^1.0.1: 10521 10540 version "1.4.1" 10522 10541 resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" ··· 11604 11623 resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" 11605 11624 integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== 11606 11625 11607 - expo-constants@18.0.8, expo-constants@^13.0.2, expo-constants@~18.0.11: 11626 + expo-constants@18.0.8, expo-constants@~18.0.11: 11608 11627 version "18.0.8" 11609 11628 resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.8.tgz#14f8388136de6e83d651bd68b326a675dfb7051c" 11610 11629 integrity sha512-Tetphsx6RVImCTZeBAclRQMy0WOODY3y6qrUoc88YGUBVm8fAKkErCSWxLTCc6nFcJxdoOMYi62LgNIUFjZCLA== ··· 11649 11668 dependencies: 11650 11669 expo-dev-menu-interface "2.0.0" 11651 11670 11652 - expo-device@7.1.4, expo-device@~4.1.1: 11671 + expo-device@7.1.4: 11653 11672 version "7.1.4" 11654 11673 resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-7.1.4.tgz#84ae7c2520cc45f15a9cb0433ae1226c33f7a8ef" 11655 11674 integrity sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q== ··· 17271 17290 hoist-non-react-statics "^3.3.0" 17272 17291 invariant "^2.2.4" 17273 17292 17274 - react-native-get-random-values@^1.6.0: 17275 - version "1.10.0" 17276 - resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.10.0.tgz#c2c5f12a4ef8b1175145347b4a4b9f9a40d9ffc8" 17277 - integrity sha512-gZ1zbXhbb8+Jy9qYTV8c4Nf45/VB4g1jmXuavY5rPfUn7x3ok9Vl3FTl0dnE92Z4FFtfbUNNwtSfcmomdtWg+A== 17278 - dependencies: 17279 - fast-base64-decode "^1.0.0" 17280 - 17281 17293 react-native-get-random-values@~1.11.0: 17282 17294 version "1.11.0" 17283 17295 resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d" ··· 18716 18728 resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" 18717 18729 integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== 18718 18730 18719 - statsig-js@4.45.1: 18720 - version "4.45.1" 18721 - resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.45.1.tgz#b1f5b9c52adc4a8aece376fb011416c89227932f" 18722 - integrity sha512-h94RzFQsJCQCNwQXpZ9OBXcvCxDnkXF6OrCekd81ySvY2l4JSowpxMWX3Iw6IDFzfTfKdER9JQzFLhMSQbT+YQ== 18723 - dependencies: 18724 - js-sha256 "^0.10.1" 18725 - uuid "^8.3.2" 18726 - 18727 18731 statsig-node@^5.23.1: 18728 18732 version "5.25.1" 18729 18733 resolved "https://registry.yarnpkg.com/statsig-node/-/statsig-node-5.25.1.tgz#6d8ea9ecaad6c09250e5ff7d33eda9fd0f9c05f4" ··· 18733 18737 node-fetch "^2.6.13" 18734 18738 ua-parser-js "^1.0.2" 18735 18739 uuid "^8.3.2" 18736 - 18737 - statsig-react-native-expo@^4.6.1: 18738 - version "4.6.1" 18739 - resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6" 18740 - integrity sha512-rB60c+WSrQPmjW9j75d+acUtwSOe38PE2KTDHiOv1Mf+0TCcFtGYlJmKCibWvbeXR7ZAyjjGeroh23bCSEZauQ== 18741 - dependencies: 18742 - "@react-native-async-storage/async-storage" "^1.15.2" 18743 - expo-constants "^13.0.2" 18744 - expo-device "~4.1.1" 18745 - js-sha256 "^0.9.0" 18746 - react-native-get-random-values "^1.6.0" 18747 - statsig-react "^1.21.1" 18748 - uuid "^8.3.2" 18749 - 18750 - statsig-react@^1.21.1: 18751 - version "1.35.0" 18752 - resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.35.0.tgz#ad5730b83f564c640623e954fcbcbe848e939946" 18753 - integrity sha512-KLN7dhq6FvAl25Z0QN6IINFBgM3yn0GMafoE698tYZqRf911xvevFaR7qUXiTz3W9vmFYrmFRouqVMfCv7DW0A== 18754 - dependencies: 18755 - statsig-js "4.45.1" 18756 18740 18757 18741 statuses@2.0.1: 18758 18742 version "2.0.1"