diff --git a/.env.example b/.env.example index e90541cabb..df3456410b 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,13 @@ EXPO_PUBLIC_CHAT_PROXY_DID= # # +# Bluesky's metrics API +EXPO_PUBLIC_METRICS_API_HOST= + +# Growthbook config +EXPO_PUBLIC_GROWTHBOOK_API_HOST= +EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= + # Sentry DSN for telemetry EXPO_PUBLIC_SENTRY_DSN= diff --git a/eslint.config.mjs b/eslint.config.mjs index c3c36b4c4f..951d3442c5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,7 +119,6 @@ export default defineConfig( }, ], 'bsky-internal/use-exact-imports': 'error', - 'bsky-internal/use-typed-gates': 'error', 'bsky-internal/use-prefixed-imports': 'error', /** diff --git a/eslint/index.js b/eslint/index.js index 3cd7e61344..4d59c8c1d0 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -8,7 +8,6 @@ const plugin = { rules: { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'use-exact-imports': require('./use-exact-imports'), - 'use-typed-gates': require('./use-typed-gates'), 'use-prefixed-imports': require('./use-prefixed-imports'), }, } diff --git a/eslint/use-typed-gates.js b/eslint/use-typed-gates.js deleted file mode 100644 index 37969951e7..0000000000 --- a/eslint/use-typed-gates.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict' - -module.exports = { - meta: { - type: 'suggestion', - docs: { - description: - 'Enforce using internal statsig wrapper instead of npm package', - }, - schema: [], - }, - create(context) { - return { - ImportSpecifier(node) { - if ( - !node.local || - node.local.type !== 'Identifier' || - node.local.name !== 'useGate' - ) { - return - } - if ( - node.parent.type !== 'ImportDeclaration' || - !node.parent.source || - node.parent.source.type !== 'Literal' - ) { - return - } - const source = node.parent.source.value - if (source.startsWith('statsig') || source.startsWith('@statsig')) { - context.report({ - node, - message: - "Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.", - }) - } - // TODO: Verify gate() call results aren't stored in variables. - }, - } - }, -} diff --git a/jest/jestSetup.js b/jest/jestSetup.js index ec5d4a9361..2b9ddf5348 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -99,19 +99,9 @@ jest.mock('expo-modules-core', () => ({ requireNativeViewManager: jest.fn().mockImplementation(_ => { return () => null }), + createPermissionHook: () => () => [true], })) jest.mock('expo-localization', () => ({ getLocales: () => [], })) - -jest.mock('statsig-react-native-expo', () => ({ - Statsig: { - initialize() {}, - initializeCalled() { - return false - }, - }, -})) - -jest.mock('../src/lib/statsig/statsig', () => ({})) diff --git a/package.json b/package.json index a10e4f97e8..ecaeb8e612 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", + "@growthbook/growthbook-react": "^1.6.2", "@haileyok/bluesky-video": "0.3.2", "@ipld/dag-cbor": "^9.2.0", "@lingui/react": "^4.14.1", @@ -219,7 +220,6 @@ "react-textarea-autosize": "^8.5.3", "sonner": "^2.0.7", "sonner-native": "^0.21.0", - "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "tldts": "^6.1.46", diff --git a/src/App.native.tsx b/src/App.native.tsx index 7f49c0a145..1568f7b8ae 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -17,7 +17,6 @@ import * as Sentry from '@sentry/react-native' import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {QueryProvider} from '#/lib/react-query' -import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' @@ -69,6 +68,12 @@ import { prefetchAgeAssuranceConfig, Provider as AgeAssuranceV2Provider, } from '#/ageAssurance' +import { + AnalyticsContext, + AnalyticsFeaturesContext, + features, + setupDeviceId, +} from '#/analytics' import {IS_ANDROID, IS_IOS} from '#/env' import { prefetchLiveEvents, @@ -114,7 +119,7 @@ function InnerApp() { if (account) { await resumeSession(account) } else { - await tryFetchGates(undefined, 'prefer-fresh-gates') + await features.init } } catch (e) { logger.error(`session: resume failed`, {message: e}) @@ -144,9 +149,9 @@ function InnerApp() { - - - + + + @@ -192,9 +197,9 @@ function InnerApp() { - - - + + + @@ -208,7 +213,7 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve()]).then(() => + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => setReady(true), ) }, []) @@ -226,30 +231,32 @@ function App() { - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 460d9ff174..a0eff936f5 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -9,7 +9,6 @@ import {useLingui} from '@lingui/react' import * as Sentry from '@sentry/react-native' import {QueryProvider} from '#/lib/react-query' -import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' @@ -55,8 +54,16 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {ToastOutlet} from '#/components/Toast' -import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' -import {prefetchAgeAssuranceConfig} from '#/ageAssurance' +import { + prefetchAgeAssuranceConfig, + Provider as AgeAssuranceV2Provider, +} from '#/ageAssurance' +import { + AnalyticsContext, + AnalyticsFeaturesContext, + features, + setupDeviceId, +} from '#/analytics' import { prefetchLiveEvents, Provider as LiveEventsProvider, @@ -87,6 +94,8 @@ function InnerApp() { try { if (account) { await resumeSession(account) + } else { + await features.init } } catch (e) { logger.error(`session: resumeSession failed`, {message: e}) @@ -119,9 +128,9 @@ function InnerApp() { - - - + + + @@ -163,9 +172,9 @@ function InnerApp() { - - - + + + @@ -179,7 +188,7 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve()]).then(() => + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => setReady(true), ) }, []) @@ -196,25 +205,27 @@ function App() { - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 36208250b8..a49ba5f248 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -28,7 +28,7 @@ import { storePayloadForAccountSwitch, } from '#/lib/hooks/useNotificationHandler' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' -import {logger as notyLogger} from '#/lib/notifications/util' +import {useCallOnce} from '#/lib/once' import {buildStateObject} from '#/lib/routes/helpers' import { type AllNavigatorParams, @@ -38,12 +38,11 @@ import { type MessagesTabNavigatorParams, type MyProfileTabNavigatorParams, type NotificationsTabNavigatorParams, + type RouteParams, type SearchTabNavigatorParams, + type State, } from '#/lib/routes/types' -import {type RouteParams, type State} from '#/lib/routes/types' -import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' -import {logger} from '#/logger' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' @@ -137,6 +136,8 @@ import { EmailDialogScreenID, useEmailDialogControl, } from '#/components/dialogs/EmailDialog' +import {useAnalytics} from '#/analytics' +import {setNavigationMetadata} from '#/analytics/metadata' import {IS_NATIVE, IS_WEB} from '#/env' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' @@ -879,11 +880,13 @@ const LINKING = { let lastHandledNotificationDateDedupe: number | undefined function RoutesContainer({children}: React.PropsWithChildren<{}>) { + const ax = useAnalytics() + const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications) const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const {currentAccount, accounts} = useSession() const {onPressSwitchAccount} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() - const prevLoggedRouteName = useRef(undefined) + const previousScreen = useRef(undefined) const emailDialogControl = useEmailDialogControl() const closeAllActiveElements = useCloseAllActiveElements() @@ -945,11 +948,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { const payload = getNotificationPayload(response.notification) if (payload) { - notyLogger.metric( - 'notifications:openApp', - {reason: payload.reason, causedBoot: true}, - {statsig: false}, - ) + ax.metric('notifications:openApp', { + reason: payload.reason, + causedBoot: true, + }) if (payload.reason === 'chat-message') { handleChatMessage(payload) @@ -973,47 +975,69 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { } } - function onReady() { - prevLoggedRouteName.current = getCurrentRouteName() + const onNavigationReady = useCallOnce(() => { + const currentScreen = getCurrentRouteName() + setNavigationMetadata({ + previousScreen: currentScreen, + currentScreen, + }) + previousScreen.current = currentScreen + + handlePushNotificationEntry() + + ax.metric('router:navigate', {}) + if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { emailDialogControl.open({ id: EmailDialogScreenID.VerificationReminder, }) snoozeEmailConfirmationPrompt() } - } + + ax.metric('init', { + initMs: Math.round( + // @ts-ignore Emitted by Metro in the bundle prelude + performance.now() - global.__BUNDLE_START_TIME__, + ), + }) + + if (IS_WEB) { + const referrerInfo = Referrer.getReferrerInfo() + if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { + ax.metric('deepLink:referrerReceived', { + to: window.location.href, + referrer: referrerInfo?.referrer, + hostname: referrerInfo?.hostname, + }) + } + } + }) return ( - <> - { - logger.metric( - 'router:navigate', - {from: prevLoggedRouteName.current}, - {statsig: false}, - ) - prevLoggedRouteName.current = getCurrentRouteName() - }} - onReady={() => { - attachRouteToLogEvents(getCurrentRouteName) - logModuleInitTime() - onReady() - logger.metric('router:navigate', {}, {statsig: false}) - handlePushNotificationEntry() - }} - // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x - // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. - // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. - // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. - // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x - // -sfn - navigationInChildEnabled> - {children} - - + { + const currentScreen = getCurrentRouteName() + // do this before metric + setNavigationMetadata({ + previousScreen: previousScreen.current, + currentScreen, + }) + ax.metric('router:navigate', {from: previousScreen.current}) + previousScreen.current = currentScreen + }} + onReady={onNavigationReady} + // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x + // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. + // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. + // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. + // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x + // -sfn + navigationInChildEnabled> + {children} + ) } @@ -1087,44 +1111,6 @@ function reset(): Promise { } } -let didInit = false -function logModuleInitTime() { - if (didInit) { - return - } - didInit = true - - const initMs = Math.round( - // @ts-ignore Emitted by Metro in the bundle prelude - performance.now() - global.__BUNDLE_START_TIME__, - ) - console.log(`Time to first paint: ${initMs} ms`) - logEvent('init', { - initMs, - }) - - if (IS_WEB) { - const referrerInfo = Referrer.getReferrerInfo() - if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { - logEvent('deepLink:referrerReceived', { - to: window.location.href, - referrer: referrerInfo?.referrer, - hostname: referrerInfo?.hostname, - }) - } - } - - if (__DEV__) { - // This log is noisy, so keep false committed - const shouldLog = false - // Relies on our patch to polyfill.js in metro-runtime - const initLogs = (global as any).__INIT_LOGS__ - if (shouldLog && Array.isArray(initLogs)) { - console.log(initLogs.join('\n')) - } - } -} - export { FlatNavigator, navigate, diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index ca5716a37a..9515b820e3 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -9,7 +9,6 @@ import { useCreateSupportLink, } from '#/lib/hooks/useCreateSupportLink' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {logger} from '#/logger' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' import {useSessionApi} from '#/state/session' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' @@ -36,8 +35,8 @@ import { isLegacyBirthdateBug, useAgeAssuranceRegionConfig, } from '#/ageAssurance/util' -import {IS_WEB} from '#/env' -import {IS_NATIVE} from '#/env' +import {useAnalytics} from '#/analytics' +import {IS_NATIVE, IS_WEB} from '#/env' import {useDeviceGeolocationApi} from '#/geolocation' const textStyles = [a.text_md, a.leading_snug] @@ -45,6 +44,7 @@ const textStyles = [a.text_md, a.leading_snug] export function NoAccessScreen() { const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() const {gtPhone} = useBreakpoints() const insets = useSafeAreaInsets() const birthdateControl = useDialogControl() @@ -63,8 +63,8 @@ export function NoAccessScreen() { useEffect(() => { // just counting overall hits here - logger.metric(`blockedGeoOverlay:shown`, {}) - logger.metric(`ageAssurance:noAccessScreen:shown`, { + ax.metric(`blockedGeoOverlay:shown`, {}) + ax.metric(`ageAssurance:noAccessScreen:shown`, { accountCreatedAt: data?.accountCreatedAt || 'unknown', isAARegion, hasDeclaredAge, @@ -103,10 +103,7 @@ export function NoAccessScreen() { label={_(msg`Click here to update your birthdate`)} style={[textStyles]} {...createStaticClick(() => { - logger.metric( - 'ageAssurance:noAccessScreen:openBirthdateDialog', - {}, - ) + ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {}) birthdateControl.open() })}> clicking here @@ -272,6 +269,7 @@ export function NoAccessScreen() { function AccessSection() { const t = useTheme() const {_, i18n} = useLingui() + const ax = useAnalytics() const control = useDialogControl() const appealControl = Dialog.useDialogControl() const locationControl = Dialog.useDialogControl() @@ -305,7 +303,7 @@ function AccessSection() { label={_(msg`Contact our moderation team`)} {...createStaticClick(() => { appealControl.open() - logger.metric('ageAssurance:appealDialogOpen', {}) + ax.metric('ageAssurance:appealDialogOpen', {}) })}> contact our moderation team {' '} @@ -321,7 +319,7 @@ function AccessSection() { color={hasInitiated ? 'secondary' : 'primary'} onPress={() => { control.open() - logger.metric('ageAssurance:initDialogOpen', { + ax.metric('ageAssurance:initDialogOpen', { hasInitiatedPreviously: hasInitiated, }) }}> diff --git a/src/ageAssurance/components/RedirectOverlay.tsx b/src/ageAssurance/components/RedirectOverlay.tsx index b7bc51df3c..dbd498447a 100644 --- a/src/ageAssurance/components/RedirectOverlay.tsx +++ b/src/ageAssurance/components/RedirectOverlay.tsx @@ -25,9 +25,8 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {refetchAgeAssuranceServerState} from '#/ageAssurance' -import {logger} from '#/ageAssurance' -import {IS_WEB} from '#/env' -import {IS_IOS} from '#/env' +import {useAnalytics} from '#/analytics' +import {IS_IOS, IS_WEB} from '#/env' export type RedirectOverlayState = { result: 'success' | 'unknown' @@ -174,6 +173,7 @@ export function RedirectOverlay() { function Inner() { const t = useTheme() + const ax = useAnalytics() const {_} = useLingui() const agent = useAgent() const polling = useRef(false) @@ -187,7 +187,7 @@ function Inner() { polling.current = true - logger.metric('ageAssurance:redirectDialogOpen', {}) + ax.metric('ageAssurance:redirectDialogOpen', {}) wait( 3e3, @@ -218,18 +218,18 @@ function Inner() { setSuccess(true) - logger.metric('ageAssurance:redirectDialogSuccess', {}) + ax.metric('ageAssurance:redirectDialogSuccess', {}) }) .catch(() => { if (unmounted.current) return setError(true) - logger.metric('ageAssurance:redirectDialogFail', {}) + ax.metric('ageAssurance:redirectDialogFail', {}) }) return () => { unmounted.current = true } - }, [agent]) + }, [ax, agent]) if (success) { return ( diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts index 897b03bbdb..28a4591747 100644 --- a/src/ageAssurance/useBeginAgeAssurance.ts +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -12,6 +12,7 @@ import {isNetworkError} from '#/lib/hooks/useCleanError' import {useAgent} from '#/state/session' import {usePatchAgeAssuranceServerState} from '#/ageAssurance' import {logger} from '#/ageAssurance/logger' +import {useAnalytics} from '#/analytics' import {BLUESKY_PROXY_DID} from '#/env' import {useGeolocation} from '#/geolocation' @@ -19,6 +20,7 @@ const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW export function useBeginAgeAssurance() { + const ax = useAnalytics() const agent = useAgent() const geolocation = useGeolocation() const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() @@ -48,15 +50,11 @@ export function useBeginAgeAssurance() { appView.sessionManager.session.accessJwt = token appView.sessionManager.session.refreshJwt = '' - logger.metric( - 'ageAssurance:api:begin', - { - platform: Platform.OS, - countryCode, - regionCode, - }, - {statsig: false}, - ) + ax.metric('ageAssurance:api:begin', { + platform: Platform.OS, + countryCode, + regionCode, + }) /* * 2s wait is good actually. Email sending takes a hot sec and this helps diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx new file mode 100644 index 0000000000..25dfea929a --- /dev/null +++ b/src/analytics/PassiveAnalytics.tsx @@ -0,0 +1,32 @@ +import {useEffect, useRef} from 'react' + +import {getCurrentState, onAppStateChange} from '#/lib/appState' +import {useAnalytics} from '#/analytics' + +/** + * Tracks passive analytics like app foreground/background time. + */ +export function PassiveAnalytics() { + const ax = useAnalytics() + const lastActive = useRef( + getCurrentState() === 'active' ? performance.now() : null, + ) + + useEffect(() => { + const sub = onAppStateChange(state => { + if (state === 'active') { + lastActive.current = performance.now() + ax.metric('state:foreground', {}) + } else if (lastActive.current !== null) { + ax.metric('state:background', { + secondsActive: Math.round( + (performance.now() - lastActive.current) / 1e3, + ), + }) + } + }) + return () => sub.remove() + }, [ax]) + + return null +} diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts new file mode 100644 index 0000000000..b28ba88474 --- /dev/null +++ b/src/analytics/features/index.ts @@ -0,0 +1,62 @@ +import {GrowthBook} from '@growthbook/growthbook-react' + +import {type Metadata} from '#/analytics/metadata' +import * as env from '#/env' + +export {Features} from '#/analytics/features/types' + +/** + * We vary the amount of time we wait for GrowthBook to fetch feature + * gates based on the strategy specified. + */ +export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates' + +const TIMEOUT_INIT = 500 // TODO should base on p99 or something +const TIMEOUT_PREFER_LOW_LATENCY = 250 +const TIMEOUT_PREFER_FRESH_GATES = 1500 + +export const features = new GrowthBook({ + apiHost: env.GROWTHBOOK_API_HOST, + clientKey: env.GROWTHBOOK_CLIENT_KEY, +}) + +/** + * Initializer promise that must be awaited before using the GrowthBook + * instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be + * fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In + * that case, we may see a flash of uncustomized content until the + * initialization completes. + */ +export const init = new Promise(async y => { + await features.init({timeout: TIMEOUT_INIT}) + y() +}) + +/** + * Refresh feature gates from GrowthBook. Updates attributes based on the + * provided account, if any. + */ +export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) { + await features.refreshFeatures({ + timeout: + strategy === 'prefer-low-latency' + ? TIMEOUT_PREFER_LOW_LATENCY + : TIMEOUT_PREFER_FRESH_GATES, + }) +} + +/** + * Converts our metadata into GrowthBook attributes and sets them. + */ +export function setAttributes({base, session, preferences}: Metadata) { + const {deviceId, sessionId, ...br} = base + features.setAttributes({ + device_id: deviceId, // GrowthBook special field + session_id: sessionId, // GrowthBook special field + user_id: session?.did, // GrowthBook special field + id: session?.did, // GrowthBook special field + ...br, + ...(session || {}), + ...(preferences || {}), + }) +} diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts new file mode 100644 index 0000000000..5cb52e8918 --- /dev/null +++ b/src/analytics/features/types.ts @@ -0,0 +1,7 @@ +export enum Features { + DebugFeedContext = 'debug_show_feedcontext', + IsBskyTeam = 'is_bsky_team_member', + DisableOnboardingFindContacts = 'disable_onboarding_find_contacts', + DisableSettingsFindContacts = 'disable_settings_find_contacts', + DisableLiveNowBeta = 'disable_live_now_beta', +} diff --git a/src/analytics/identifiers/device.ts b/src/analytics/identifiers/device.ts new file mode 100644 index 0000000000..ac50143548 --- /dev/null +++ b/src/analytics/identifiers/device.ts @@ -0,0 +1,26 @@ +import uuid from 'react-native-uuid' +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {device} from '#/storage' + +const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID' + +export async function getAndMigrateDeviceId() { + const migrated = getDeviceId() + if (migrated) return migrated + const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4() + device.set(['deviceId'], id) + return id +} + +export function getDeviceId() { + return device.get(['deviceId']) +} + +export function getDeviceIdOrThrow() { + const id = device.get(['deviceId']) + if (!id) { + throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`) + } + return id +} diff --git a/src/analytics/identifiers/index.ts b/src/analytics/identifiers/index.ts new file mode 100644 index 0000000000..b9b9786f89 --- /dev/null +++ b/src/analytics/identifiers/index.ts @@ -0,0 +1,2 @@ +export * from '#/analytics/identifiers/device' +export * from '#/analytics/identifiers/session' diff --git a/src/analytics/identifiers/session.test.ts b/src/analytics/identifiers/session.test.ts new file mode 100644 index 0000000000..ab334ff497 --- /dev/null +++ b/src/analytics/identifiers/session.test.ts @@ -0,0 +1,79 @@ +jest.mock('#/storage', () => ({ + device: { + get: jest.fn(), + set: jest.fn(), + }, +})) + +jest.mock('#/analytics/identifiers/util', () => ({ + isSessionIdExpired: jest.fn(), +})) + +jest.mock('#/lib/appState', () => ({ + onAppStateChange: jest.fn(() => ({remove: jest.fn()})), +})) + +beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() +}) + +function getMocks() { + const {device} = require('#/storage') + const {isSessionIdExpired} = require('#/analytics/identifiers/util') + return { + device: jest.mocked(device), + isSessionIdExpired: jest.mocked(isSessionIdExpired), + } +} + +describe('session initialization', () => { + it('creates new session and sets timestamp when none exists', () => { + const {device, isSessionIdExpired} = getMocks() + device.get.mockReturnValue(undefined) + isSessionIdExpired.mockReturnValue(false) + + const {getInitialSessionId} = require('./session') + const id = getInitialSessionId() + + expect(id).toBeDefined() + expect(typeof id).toBe('string') + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) + expect(device.set).toHaveBeenCalledWith( + ['nativeSessionIdLastEventAt'], + expect.any(Number), + ) + }) + + it('reuses existing session when not expired', () => { + const {device, isSessionIdExpired} = getMocks() + const existingId = 'existing-session-id' + device.get.mockImplementation((key: string[]) => { + if (key[0] === 'nativeSessionId') return existingId + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() + return undefined + }) + isSessionIdExpired.mockReturnValue(false) + + const {getInitialSessionId} = require('./session') + + expect(getInitialSessionId()).toBe(existingId) + }) + + it('creates new session when existing is expired', () => { + const {device, isSessionIdExpired} = getMocks() + const existingId = 'existing-session-id' + device.get.mockImplementation((key: string[]) => { + if (key[0] === 'nativeSessionId') return existingId + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999 + return undefined + }) + isSessionIdExpired.mockReturnValue(true) + + const {getInitialSessionId} = require('./session') + const id = getInitialSessionId() + + expect(id).not.toBe(existingId) + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) + }) +}) diff --git a/src/analytics/identifiers/session.ts b/src/analytics/identifiers/session.ts new file mode 100644 index 0000000000..e94ece704f --- /dev/null +++ b/src/analytics/identifiers/session.ts @@ -0,0 +1,40 @@ +import {useEffect, useState} from 'react' +import uuid from 'react-native-uuid' + +import {onAppStateChange} from '#/lib/appState' +import {isSessionIdExpired} from '#/analytics/identifiers/util' +import {device} from '#/storage' + +let sessionId = (() => { + const existing = device.get(['nativeSessionId']) + const lastEvent = device.get(['nativeSessionIdLastEventAt']) + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() + device.set(['nativeSessionId'], id) + device.set(['nativeSessionIdLastEventAt'], Date.now()) + return id +})() + +export function getInitialSessionId() { + return sessionId +} + +export function useSessionId() { + const [id, setId] = useState(() => sessionId) + + useEffect(() => { + const sub = onAppStateChange(state => { + if (state === 'active') { + const lastEvent = device.get(['nativeSessionIdLastEventAt']) + if (isSessionIdExpired(lastEvent)) { + sessionId = uuid.v4() + device.set(['nativeSessionId'], sessionId) + setId(sessionId) + } + } + device.set(['nativeSessionIdLastEventAt'], Date.now()) + }) + return () => sub.remove() + }, []) + + return id +} diff --git a/src/analytics/identifiers/session.web.ts b/src/analytics/identifiers/session.web.ts new file mode 100644 index 0000000000..c2b2090cf0 --- /dev/null +++ b/src/analytics/identifiers/session.web.ts @@ -0,0 +1,44 @@ +import {useEffect, useState} from 'react' +import uuid from 'react-native-uuid' + +import {onAppStateChange} from '#/lib/appState' +import {isSessionIdExpired} from '#/analytics/identifiers/util' + +const SESSION_ID_KEY = 'bsky_session_id' +const LAST_EVENT_KEY = 'bsky_session_id_last_event_at' + +let sessionId = (() => { + const existing = window.sessionStorage.getItem(SESSION_ID_KEY) + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() + window.sessionStorage.setItem(SESSION_ID_KEY, id) + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) + return id +})() + +export function getInitialSessionId() { + return sessionId +} + +export function useSessionId() { + const [id, setId] = useState(() => sessionId) + + useEffect(() => { + const sub = onAppStateChange(state => { + if (state === 'active') { + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined + if (isSessionIdExpired(lastEvent)) { + sessionId = uuid.v4() + window.sessionStorage.setItem(SESSION_ID_KEY, sessionId) + setId(sessionId) + } + } + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) + }) + return () => sub.remove() + }, []) + + return id +} diff --git a/src/analytics/identifiers/util.ts b/src/analytics/identifiers/util.ts new file mode 100644 index 0000000000..2d68f9b902 --- /dev/null +++ b/src/analytics/identifiers/util.ts @@ -0,0 +1,9 @@ +import * as env from '#/env' + +const ONE_MIN = 60 * 1e3 +const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native + +export function isSessionIdExpired(since: number | undefined) { + if (since === undefined) return false + return Date.now() - since >= TTL +} diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx new file mode 100644 index 0000000000..9d80ee54ba --- /dev/null +++ b/src/analytics/index.tsx @@ -0,0 +1,229 @@ +import {createContext, useContext, useEffect, useMemo} from 'react' +import {Platform} from 'react-native' + +import {Logger} from '#/logger' +import { + Features, + features as feats, + init, + refresh, + setAttributes, +} from '#/analytics/features' +import { + getAndMigrateDeviceId, + getDeviceId, + getInitialSessionId, + useSessionId, +} from '#/analytics/identifiers' +import { + getNavigationMetadata, + type MergeableMetadata, + type Metadata, +} from '#/analytics/metadata' +import {type Metrics, metrics} from '#/analytics/metrics' +import * as refParams from '#/analytics/misc/refParams' +import {getMetadataForLogger} from '#/analytics/utils' +import * as env from '#/env' +import {useGeolocation} from '#/geolocation' +import {device} from '#/storage' + +export * as utils from '#/analytics/utils' +export const features = {init, refresh} +export {Features} from '#/analytics/features' +export {type Metrics} from '#/analytics/metrics' + +type LoggerType = { + debug: Logger['debug'] + info: Logger['info'] + log: Logger['log'] + warn: Logger['warn'] + error: Logger['error'] + /** + * Clones the existing logger and overrides the `context` value. Existing + * metadata is inherited. + * + * ```ts + * const ax = useAnalytics() + * const logger = ax.logger.useChild(ax.logger.Context.Notifications) + * ``` + */ + useChild: (context: Exclude) => LoggerType + Context: typeof Logger.Context +} +export type AnalyticsContextType = { + metadata: Metadata + logger: LoggerType + metric: ( + event: E, + payload: Metrics[E], + metadata?: MergeableMetadata, + ) => void + features: typeof Features & { + enabled(feature: Features): boolean + } +} +export type AnalyticsBaseContextType = Omit + +function createLogger( + context: Logger['context'], + metadata: Partial, +): LoggerType { + const logger = Logger.create(context, metadata) + return { + debug: logger.debug.bind(logger), + info: logger.info.bind(logger), + log: logger.log.bind(logger), + warn: logger.warn.bind(logger), + error: logger.error.bind(logger), + useChild: (context: Exclude) => { + return useMemo(() => createLogger(context, metadata), [context, metadata]) + }, + Context: Logger.Context, + } +} + +const Context = createContext({ + logger: createLogger(Logger.Context.Default, {}), + metric: (event, payload, metadata) => { + if (metadata && '__meta' in metadata) { + delete metadata.__meta + } + metrics.track(event, payload, { + ...metadata, + navigation: getNavigationMetadata(), + }) + }, + metadata: { + base: { + deviceId: getDeviceId() ?? 'unknown', + sessionId: getInitialSessionId(), + platform: Platform.OS, + appVersion: env.APP_VERSION, + bundleIdentifier: env.BUNDLE_IDENTIFIER, + bundleDate: env.BUNDLE_DATE, + referrerSrc: refParams.src, + referrerUrl: refParams.url, + }, + geolocation: device.get(['mergedGeolocation']) || { + countryCode: '', + regionCode: '', + }, + }, +}) + +/** + * Ensures that deviceId is set and migrated from legacy storage. Handled on + * startup in `App..tsx`. This must be awaited prior to the app + * booting up. + */ +export const setupDeviceId = getAndMigrateDeviceId() + +/** + * Analytics context provider. Decorates the parent analytics context with + * additional metadata. Nesting should be done carefully and sparingly. + */ +export function AnalyticsContext({ + children, + metadata, +}: { + children: React.ReactNode + metadata?: MergeableMetadata +}) { + if (metadata) { + if (!('__meta' in metadata)) { + throw new Error( + 'Use the useMeta() helper when passing metadata to AnalyticsContext', + ) + } + } + const sessionId = useSessionId() + const geolocation = useGeolocation() + const parentContext = useContext(Context) + const childContext = useMemo(() => { + const combinedMetadata = { + ...parentContext.metadata, + ...metadata, + base: { + ...parentContext.metadata.base, + sessionId, + }, + geolocation, + } + const context: AnalyticsBaseContextType = { + ...parentContext, + logger: createLogger( + Logger.Context.Default, + getMetadataForLogger(combinedMetadata), + ), + metadata: combinedMetadata, + metric: (event, payload, extraMetadata) => { + parentContext.metric(event, payload, { + ...combinedMetadata, + ...extraMetadata, + }) + }, + } + return context + }, [sessionId, geolocation, parentContext, metadata]) + return {children} +} + +/** + * Feature gates provider. Decorates the parent analytics context with + * feature gate capabilities. Should be mounted within `AnalyticsContext`, + * and below the `` breaker in `App..tsx`. + */ +export function AnalyticsFeaturesContext({ + children, +}: { + children: React.ReactNode +}) { + const parentContext = useContext(Context) + + useEffect(() => { + feats.setTrackingCallback((experiment, result) => { + parentContext.metric('experiment:viewed', { + experimentId: experiment.key, + variationId: result.key, + }) + }) + }, [parentContext.metric]) + + useEffect(() => { + setAttributes(parentContext.metadata) + }, [parentContext.metadata]) + + const childContext = useMemo(() => { + return { + ...parentContext, + features: { + enabled: feats.isOn.bind(feats), + ...Features, + }, + } + }, [parentContext]) + + return {children} +} + +/** + * Basic analytics context without feature gates. Should really only be used + * above the `AnalyticsFeaturesContext` provider. + */ +export function useAnalyticsBase() { + return useContext(Context) +} + +/** + * The main analytics context, including feature gates. Use this everywhere you + * need metrics, features, or logging within the React tree. + */ +export function useAnalytics() { + const ctx = useContext(Context) + if (!('features' in ctx)) { + throw new Error( + 'useAnalytics must be used within an AnalyticsFeaturesContext', + ) + } + return ctx as AnalyticsContextType +} diff --git a/src/analytics/metadata.ts b/src/analytics/metadata.ts new file mode 100644 index 0000000000..f097c17a76 --- /dev/null +++ b/src/analytics/metadata.ts @@ -0,0 +1,61 @@ +import {type Geolocation} from '#/geolocation' + +export type BaseMetadata = { + deviceId: string + sessionId: string + platform: string + appVersion: string + bundleIdentifier: string + bundleDate: number + referrerSrc: string + referrerUrl: string +} + +export type GeolocationMetadata = Geolocation + +export type SessionMetadata = { + did: string + isBskyPds: boolean +} + +export type PreferencesMetadata = { + appLanguage: string + contentLanguages: string[] +} + +export type MergeableMetadata = { + session?: SessionMetadata + preferences?: PreferencesMetadata + /** + * Navigation metadata is not actually available on this object, instead it's + * merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details. + */ + navigation?: NavigationMetadata +} + +export type Metadata = { + base: BaseMetadata + geolocation: GeolocationMetadata +} & MergeableMetadata + +/* + * Navigation metadata is handle out-of-band from React, since we don't want to + * slow down screen transitions in any way, and there doesn't seem to be a nice + * way to get current navigation state without an additional re-render between + * navigations. + * + * So instead of this data being available on the Metadata object, it's stored + * here and merged in at time-of-log/metric. + */ +export type NavigationMetadata = { + previousScreen?: string + currentScreen?: string +} +let navigationMetadata: NavigationMetadata | undefined +export function getNavigationMetadata() { + console.log('metadata', JSON.stringify(navigationMetadata, null, 2)) + return navigationMetadata +} +export function setNavigationMetadata(meta: NavigationMetadata | undefined) { + navigationMetadata = meta +} diff --git a/src/analytics/metrics/client.test.ts b/src/analytics/metrics/client.test.ts new file mode 100644 index 0000000000..000e2894bf --- /dev/null +++ b/src/analytics/metrics/client.test.ts @@ -0,0 +1,176 @@ +import {MetricsClient} from './client' + +let appStateCallback: (state: string) => void + +jest.mock('#/lib/appState', () => ({ + onAppStateChange: jest.fn(cb => { + appStateCallback = cb + return {remove: jest.fn()} + }), +})) + +jest.mock('#/logger', () => ({ + Logger: { + create: () => ({ + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }), + Context: {Metric: 'metric'}, + }, +})) + +jest.mock('#/env', () => ({ + METRICS_API_HOST: 'https://test.metrics.api', + IS_WEB: false, +})) + +type TestEvents = { + click: {button: string} + view: {screen: string} +} + +describe('MetricsClient', () => { + let fetchMock: jest.Mock + let fetchRequests: {body: any}[] + + beforeEach(() => { + jest.useFakeTimers({advanceTimers: true}) + fetchRequests = [] + fetchMock = jest.fn().mockImplementation(async (_url, options) => { + const body = JSON.parse(options.body) + fetchRequests.push({body}) + return {ok: true, status: 200} + }) + global.fetch = fetchMock + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('flushes events on interval', async () => { + const client = new MetricsClient() + client.track('click', {button: 'submit'}) + client.track('view', {screen: 'home'}) + + expect(fetchRequests).toHaveLength(0) + + // Advance past the 10 second interval + await jest.advanceTimersByTimeAsync(10_000) + + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(2) + expect(fetchRequests[0].body.events[0].event).toBe('click') + expect(fetchRequests[0].body.events[1].event).toBe('view') + }) + + it('flushes when maxBatchSize is exceeded', async () => { + const client = new MetricsClient() + client.maxBatchSize = 5 + + // Add events up to maxBatchSize (should not flush yet) + for (let i = 0; i < 5; i++) { + client.track('click', {button: `btn-${i}`}) + } + + expect(fetchRequests).toHaveLength(0) + + // One more event should trigger flush (> maxBatchSize) + client.track('click', {button: 'btn-trigger'}) + + // Allow microtasks to run + await jest.advanceTimersByTimeAsync(0) + + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(6) + }) + + it('retries failed events once on 500 response', async () => { + let requestCount = 0 + + fetchMock.mockImplementation(async (_url, options) => { + requestCount++ + const body = JSON.parse(options.body) + + if (requestCount === 1) { + // First request fails with 500 - "Failed to fetch" triggers isNetworkError + return { + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } + } + + // Retry succeeds + fetchRequests.push({body}) + return {ok: true, status: 200} + }) + + const client = new MetricsClient() + client.track('click', {button: 'submit'}) + + // Trigger flush via interval + await jest.advanceTimersByTimeAsync(10_000) + + expect(requestCount).toBe(1) + expect(fetchRequests).toHaveLength(0) + + // Simulate app coming to foreground to trigger retry + appStateCallback('active') + await jest.advanceTimersByTimeAsync(0) + + expect(requestCount).toBe(2) + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(1) + expect(fetchRequests[0].body.events[0].event).toBe('click') + }) + + it('does not retry more than once', async () => { + let requestCount = 0 + + fetchMock.mockImplementation(async () => { + requestCount++ + // Always fail with network-like error + return { + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } + }) + + const client = new MetricsClient() + client.track('click', {button: 'submit'}) + + // First flush fails + await jest.advanceTimersByTimeAsync(10_000) + + expect(requestCount).toBe(1) + + // Retry also fails + appStateCallback('active') + await jest.advanceTimersByTimeAsync(0) + + expect(requestCount).toBe(2) + + // Another foreground event should not retry again (events are dropped) + appStateCallback('active') + await jest.advanceTimersByTimeAsync(0) + + expect(requestCount).toBe(2) // No additional requests + }) + + it('flushes when app goes to background', async () => { + const client = new MetricsClient() + client.track('click', {button: 'submit'}) + + expect(fetchRequests).toHaveLength(0) + + // Simulate app going to background + appStateCallback('background') + await jest.advanceTimersByTimeAsync(0) + + expect(fetchRequests).toHaveLength(1) + }) +}) diff --git a/src/analytics/metrics/client.ts b/src/analytics/metrics/client.ts new file mode 100644 index 0000000000..8ed02f3804 --- /dev/null +++ b/src/analytics/metrics/client.ts @@ -0,0 +1,116 @@ +import {onAppStateChange} from '#/lib/appState' +import {isNetworkError} from '#/lib/strings/errors' +import {Logger} from '#/logger' +import * as env from '#/env' + +type Event> = { + time: number + event: keyof M + payload: M[keyof M] + metadata: Record +} + +const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t' +const logger = Logger.create(Logger.Context.Metric, {}) + +export class MetricsClient> { + maxBatchSize = 100 + + private started: boolean = false + private queue: Event[] = [] + private failedQueue: Event[] = [] + private flushInterval: NodeJS.Timeout | null = null + + start() { + if (this.started) return + this.started = true + this.flushInterval = setInterval(() => { + this.flush() + }, 10_000) + onAppStateChange(state => { + if (state === 'active') { + this.retryFailedLogs() + } else { + this.flush() + } + }) + } + + track( + event: E, + payload: M[E], + metadata: Record = {}, + ) { + this.start() + + const e = { + time: Date.now(), + event, + payload, + metadata, + } + this.queue.push(e) + + logger.info(`event: ${e.event as string}`, e) + + if (this.queue.length > this.maxBatchSize) { + this.flush() + } + } + + flush() { + if (!this.queue.length) return + const events = this.queue.splice(0, this.queue.length) + this.sendBatch(events) + } + + private async sendBatch(events: Event[], isRetry: boolean = false) { + logger.debug(`sendBatch: ${events.length}`, { + isRetry, + }) + + try { + const body = JSON.stringify({events}) + if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { + const success = navigator.sendBeacon( + TRACKING_ENDPOINT, + new Blob([body], {type: 'application/json'}), + ) + if (!success) { + // construct a "network error" for `isNetworkError` to work + throw new Error(`Failed to fetch: sendBeacon returned false`) + } + } else { + const res = await fetch(TRACKING_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({events}), + keepalive: true, + }) + + if (!res.ok) { + const error = await res.text().catch(() => 'Unknown error') + // construct a "network error" for `isNetworkError` to work + throw new Error(`${res.status} Failed to fetch — ${error}`) + } + } + } catch (e: any) { + if (isNetworkError(e)) { + if (isRetry) return // retry once + this.failedQueue.push(...events) + return + } + logger.error(`Failed to send metrics`, { + safeMessage: e.toString(), + }) + } + } + + private retryFailedLogs() { + if (!this.failedQueue.length) return + const events = this.failedQueue.splice(0, this.failedQueue.length) + this.sendBatch(events, true) + } +} diff --git a/src/analytics/metrics/index.ts b/src/analytics/metrics/index.ts new file mode 100644 index 0000000000..273e710c0d --- /dev/null +++ b/src/analytics/metrics/index.ts @@ -0,0 +1,6 @@ +import {MetricsClient} from '#/analytics/metrics/client' +import {type Events} from '#/analytics/metrics/types' + +export type {Events as Metrics} from '#/analytics/metrics/types' +export * from '#/analytics/metrics/utils' +export const metrics = new MetricsClient() diff --git a/src/logger/metrics.ts b/src/analytics/metrics/types.ts similarity index 99% rename from src/logger/metrics.ts rename to src/analytics/metrics/types.ts index 7afe7f3bbe..0fa5279edf 100644 --- a/src/logger/metrics.ts +++ b/src/analytics/metrics/types.ts @@ -1,12 +1,21 @@ +/* + * Do not import runtime code into this file + */ + import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' -export type MetricEvents = { +export type Events = { // App events init: { initMs: number } + 'experiment:viewed': { + experimentId: string + variationId: string + } + 'account:loggedIn': { logContext: | 'LoginForm' @@ -139,6 +148,7 @@ export type MetricEvents = { feedUrl: string feedType: string index: number + reason?: string } 'feed:endReached': { feedUrl: string @@ -374,7 +384,6 @@ export type MetricEvents = { | 'AvatarButton' | 'StarterPackProfilesList' | 'FeedInterstitial' - | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' @@ -468,7 +477,6 @@ export type MetricEvents = { | 'AvatarButton' | 'StarterPackProfilesList' | 'FeedInterstitial' - | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' diff --git a/src/analytics/metrics/utils.ts b/src/analytics/metrics/utils.ts new file mode 100644 index 0000000000..7cca04f924 --- /dev/null +++ b/src/analytics/metrics/utils.ts @@ -0,0 +1,7 @@ +export function toClout(n: number | null | undefined): number | undefined { + if (n == null) { + return undefined + } else { + return Math.max(0, Math.round(Math.log(n))) + } +} diff --git a/src/analytics/misc/refParams.ts b/src/analytics/misc/refParams.ts new file mode 100644 index 0000000000..2721c43ab5 --- /dev/null +++ b/src/analytics/misc/refParams.ts @@ -0,0 +1,18 @@ +/** + * This is used for our own Bluesky post embeds, and maybe other things. + * + * In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used. + */ + +import * as env from '#/env' + +let refSrc = '' +let refUrl = '' +if (env.IS_WEB) { + const params = new URLSearchParams(window.location.search) + refSrc = params.get('ref_src') ?? '' + refUrl = decodeURIComponent(params.get('ref_url') ?? '') +} + +export const src = refSrc +export const url = refUrl diff --git a/src/analytics/utils.ts b/src/analytics/utils.ts new file mode 100644 index 0000000000..38f7528824 --- /dev/null +++ b/src/analytics/utils.ts @@ -0,0 +1,50 @@ +import {useMemo} from 'react' + +import {BSKY_SERVICE} from '#/lib/constants' +import {type SessionAccount} from '#/state/session' +import { + type MergeableMetadata, + type Metadata, + type SessionMetadata, +} from '#/analytics/metadata' + +/** + * Thin `useMemo` wrapper that marks the metadata as memoized and provides a + * type guard. + */ +export function useMeta(metadata?: MergeableMetadata) { + const m = useMemo(() => metadata, [metadata]) + if (!m) return + // @ts-ignore + m.__meta = true + return m +} + +export function accountToSessionMetadata( + account: SessionAccount | undefined, +): SessionMetadata | undefined { + if (!account) { + return + } else { + return { + did: account.did, + isBskyPds: account.service.startsWith(BSKY_SERVICE), + } + } +} + +export function getMetadataForLogger({ + base, + geolocation, + session, +}: Metadata): Record { + return { + deviceId: base.deviceId, + sessionId: base.sessionId, + platform: base.platform, + appVersion: base.appVersion, + countryCode: geolocation.countryCode, + regionCode: geolocation.regionCode, + isBskyPds: session?.isBskyPds || 'anonymous', + } +} diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 14887adb7c..37ed7ce21b 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -7,9 +7,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' -import {logEvent} from '#/lib/statsig/statsig' -import {logger} from '#/logger' -import {type MetricEvents} from '#/logger/metrics' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {type FeedDescriptor} from '#/state/queries/post-feed' @@ -38,6 +35,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' +import {type Metrics, useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import type * as bsky from '#/types/bsky' import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' @@ -434,6 +432,7 @@ export function ProfileGrid({ isVisible?: boolean }) { const t = useTheme() + const ax = useAnalytics() const {_} = useLingui() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() @@ -450,12 +449,11 @@ export function ProfileGrid({ const seenProfilesRef = useRef>(new Set()) const containerRef = useRef(null) const hasTrackedRef = useRef(false) - const logContext: MetricEvents['suggestedUser:seen']['logContext'] = - isFeedContext - ? 'InterstitialDiscover' - : isProfileHeaderContext - ? 'Profile' - : 'InterstitialProfile' + const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext + ? 'InterstitialDiscover' + : isProfileHeaderContext + ? 'Profile' + : 'InterstitialProfile' // Callback to fire seen events const fireSeen = useCallback(() => { @@ -467,20 +465,16 @@ export function ProfileGrid({ profilesToShow.forEach((profile, index) => { if (!seenProfilesRef.current.has(profile.did)) { seenProfilesRef.current.add(profile.did) - logger.metric( - 'suggestedUser:seen', - { - logContext, - recId, - position: index, - suggestedDid: profile.did, - category: null, - }, - {statsig: true}, - ) + ax.metric('suggestedUser:seen', { + logContext, + recId, + position: index, + suggestedDid: profile.did, + category: null, + }) } }) - }, [isLoading, error, profiles, maxLength, logContext, recId]) + }, [ax, isLoading, error, profiles, maxLength, logContext, recId]) // For profile header, fire when isVisible becomes true useEffect(() => { @@ -565,7 +559,7 @@ export function ProfileGrid({ { - logEvent('suggestedUser:press', { + ax.metric('suggestedUser:press', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', @@ -588,7 +582,7 @@ export function ProfileGrid({ onPress={e => { e.preventDefault() onDismiss(profile.did) - logEvent('suggestedUser:dismiss', { + ax.metric('suggestedUser:dismiss', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', @@ -656,7 +650,7 @@ export function ProfileGrid({ withIcon={false} style={[a.rounded_sm]} onFollow={() => { - logEvent('suggestedUser:follow', { + ax.metric('suggestedUser:follow', { logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', @@ -678,7 +672,7 @@ export function ProfileGrid({ // Use totalProfileCount (before dismissals) for minLength check on initial render. const profileCountForMinCheck = totalProfileCount ?? profiles.length if (error || (!isLoading && profileCountForMinCheck < minLength)) { - logger.debug(`Not enough profiles to show suggested follows`) + ax.logger.debug(`Not enough profiles to show suggested follows`) return null } @@ -712,7 +706,7 @@ export function ProfileGrid({ label={_(msg`See more suggested profiles`)} onPress={() => { followDialogControl.open() - logEvent('suggestedUser:seeMore', { + ax.metric('suggestedUser:seeMore', { logContext: isFeedContext ? 'Explore' : 'Profile', }) }}> @@ -756,7 +750,7 @@ export function ProfileGrid({ { followDialogControl.open() - logger.metric('suggestedUser:seeMore', { + ax.metric('suggestedUser:seeMore', { logContext: 'Explore', }) }} @@ -794,9 +788,10 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) { ) } +const numFeedsToDisplay = 3 export function SuggestedFeeds() { - const numFeedsToDisplay = 3 const t = useTheme() + const ax = useAnalytics() const {_} = useLingui() const {data, isLoading, error} = useGetPopularFeedsQuery({ limit: numFeedsToDisplay, @@ -829,7 +824,7 @@ export function SuggestedFeeds() { key={feed.uri} view={feed} onPress={() => { - logEvent('feed:interstitial:feedCard:press', {}) + ax.metric('feed:interstitial:feedCard:press', {}) }}> {({hovered, pressed}) => ( { const {hasSession, currentAccount} = useSession() const {_} = useLingui() + const ax = useAnalytics() const langPrefs = useLanguagePrefs() const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: pinPostMutate, isPending: isPinPending} = @@ -212,7 +220,7 @@ let PostMenuItems = ({ try { if (isThreadMuted) { unmuteThread() - logger.metric('post:unmute', { + ax.metric('post:unmute', { uri: postUri, authorDid: postAuthor.did, logContext, @@ -221,7 +229,7 @@ let PostMenuItems = ({ Toast.show(_(msg`You will now receive notifications for this thread`)) } else { muteThread() - logger.metric('post:mute', { + ax.metric('post:mute', { uri: postUri, authorDid: postAuthor.did, logContext, @@ -258,21 +266,17 @@ let PostMenuItems = ({ AppBskyFeedPost.isRecord, ) ) { - logger.metric( - 'translate', - { - sourceLanguages: post.record.langs ?? [], - targetLanguage: langPrefs.primaryLanguage, - textLength: post.record.text.length, - }, - {statsig: false}, - ) + ax.metric('translate', { + sourceLanguages: post.record.langs ?? [], + targetLanguage: langPrefs.primaryLanguage, + textLength: post.record.text.length, + }) } } const onHidePost = () => { hidePost({uri: postUri}) - logEvent('thread:click:hideReplyForMe', {}) + ax.metric('thread:click:hideReplyForMe', {}) } const hideInPWI = !!postAuthor.labels?.find( @@ -286,7 +290,7 @@ let PostMenuItems = ({ feedContext: postFeedContext, reqId: postReqId, }) - logger.metric('post:showMore', { + ax.metric('post:showMore', { uri: postUri, authorDid: postAuthor.did, logContext, @@ -304,7 +308,7 @@ let PostMenuItems = ({ feedContext: postFeedContext, reqId: postReqId, }) - logger.metric('post:showLess', { + ax.metric('post:showLess', { uri: postUri, authorDid: postAuthor.did, logContext, @@ -368,7 +372,7 @@ let PostMenuItems = ({ // Log metric only when hiding (not when showing) if (isHide) { - logEvent('thread:click:hideReplyForEveryone', {}) + ax.metric('thread:click:hideReplyForEveryone', {}) } Toast.show( @@ -405,7 +409,7 @@ let PostMenuItems = ({ } const onPressPin = () => { - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) + ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, @@ -458,11 +462,10 @@ let PostMenuItems = ({ const onSignIn = () => requireSignIn(() => {}) - const gate = useGate() const isDiscoverDebugUser = IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || - gate('debug_show_feedcontext') + ax.features.enabled(ax.features.DebugFeedContext) return ( <> diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index e17a6adb2a..2648b485e1 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -8,7 +8,6 @@ import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {type NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' @@ -20,9 +19,11 @@ import {useDialogContext} from '#/components/Dialog' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' export function RecentChats({postUri}: {postUri: string}) { + const ax = useAnalytics() const control = useDialogContext() const {currentAccount} = useSession() const {data} = useListConvosQuery({status: 'accepted'}) @@ -32,7 +33,7 @@ export function RecentChats({postUri}: {postUri: string}) { const onSelectChat = (convoId: string) => { control.close(() => { - logger.metric('share:press:recentDm', {}, {statsig: true}) + ax.metric('share:press:recentDm', {}) navigation.navigate('MessagesConversation', { conversation: convoId, embed: postUri, diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index 8ba9ea3e25..fe2f0a9c77 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -9,7 +9,6 @@ import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' -import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' @@ -23,6 +22,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import * as Menu from '#/components/Menu' import {useAgeAssurance} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' import {useDevMode} from '#/storage/hooks/dev-mode' import {RecentChats} from './RecentChats' @@ -32,6 +32,7 @@ let ShareMenuItems = ({ post, onShare: onShareProp, }: ShareMenuItemsProps): React.ReactNode => { + const ax = useAnalytics() const {hasSession} = useSession() const {_} = useLingui() const navigation = useNavigation() @@ -54,14 +55,14 @@ let ShareMenuItems = ({ }, [postAuthor]) const onSharePost = () => { - logger.metric('share:press:nativeShare', {}, {statsig: true}) + ax.metric('share:press:nativeShare', {}) const url = toShareUrl(href) shareUrl(url) onShareProp() } const onCopyLink = async () => { - logger.metric('share:press:copyLink', {}, {statsig: true}) + ax.metric('share:press:copyLink', {}) const url = toShareUrl(href) if (IS_IOS) { // iOS only @@ -100,7 +101,7 @@ let ShareMenuItems = ({ testID="postDropdownSendViaDMBtn" label={_(msg`Send via direct message`)} onPress={() => { - logger.metric('share:press:openDmSearch', {}, {statsig: true}) + ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx index 408035db90..c1da73ca82 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -8,7 +8,6 @@ import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' -import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {useBreakpoints} from '#/alf' @@ -21,6 +20,7 @@ import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/compon import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' import * as Menu from '#/components/Menu' import {useAgeAssurance} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import {useDevMode} from '#/storage/hooks/dev-mode' import {type ShareMenuItemsProps} from './ShareMenuItems.types' @@ -31,6 +31,7 @@ let ShareMenuItems = ({ timestamp, onShare: onShareProp, }: ShareMenuItemsProps): React.ReactNode => { + const ax = useAnalytics() const {hasSession} = useSession() const {gtMobile} = useBreakpoints() const {_} = useLingui() @@ -56,14 +57,14 @@ let ShareMenuItems = ({ }, [postAuthor]) const onCopyLink = () => { - logger.metric('share:press:copyLink', {}, {statsig: true}) + ax.metric('share:press:copyLink', {}) const url = toShareUrl(href) shareUrl(url) onShareProp() } const onSelectChatToShareTo = (conversation: string) => { - logger.metric('share:press:dmSelected', {}, {statsig: true}) + ax.metric('share:press:dmSelected', {}) navigation.navigate('MessagesConversation', { conversation, embed: postUri, @@ -102,7 +103,7 @@ let ShareMenuItems = ({ testID="postDropdownSendViaDMBtn" label={_(msg`Send via direct message`)} onPress={() => { - logger.metric('share:press:openDmSearch', {}, {statsig: true}) + ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> @@ -117,7 +118,7 @@ let ShareMenuItems = ({ testID="postDropdownEmbedBtn" label={_(msg`Embed post`)} onPress={() => { - logger.metric('share:press:embed', {}, {statsig: true}) + ax.metric('share:press:embed', {}) embedPostControl.open() }}> {_(msg`Embed post`)} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index 0748f1a371..b53bd63c01 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -13,7 +13,6 @@ import {useLingui} from '@lingui/react' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' -import {logger} from '#/logger' import {type Shadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {EventStopper} from '#/view/com/util/EventStopper' @@ -21,6 +20,7 @@ import {native} from '#/alf' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' +import {useAnalytics} from '#/analytics' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {ShareMenuItems} from './ShareMenuItems' @@ -47,6 +47,7 @@ let ShareMenuButton = ({ hitSlop?: Insets logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' }): React.ReactNode => { + const ax = useAnalytics() const {_} = useLingui() const {feedDescriptor} = useFeedFeedbackContext() @@ -61,20 +62,17 @@ let ShareMenuButton = ({ // menuControl.open() fires but RN doesn't expose flushSync. setTimeout(menuControl.open) - logger.metric( - 'post:share', - { - uri: post.uri, - authorDid: post.author.did, - logContext, - feedDescriptor, - postContext: big ? 'thread' : 'feed', - }, - {statsig: true}, - ) + ax.metric('post:share', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + postContext: big ? 'thread' : 'feed', + }) }, }), [ + ax, menuControl, setHasBeenOpen, big, @@ -86,7 +84,7 @@ let ShareMenuButton = ({ ) const onNativeLongPress = () => { - logger.metric('share:press:nativeShare', {}, {statsig: true}) + ax.metric('share:press:nativeShare', {}) const urip = new AtUri(post.uri) const href = makeProfileLink(post.author, 'post', urip.rkey) const url = toShareUrl(href) diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index 45688c2617..4eb67879ab 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -13,7 +13,6 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {logger} from '#/logger' import {type Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' import { @@ -30,6 +29,7 @@ import {atoms as a, useBreakpoints} from '#/alf' import {Reply as Bubble} from '#/components/icons/Reply' import {useFormatPostStatCount} from '#/components/PostControls/util' import * as Skele from '#/components/Skeleton' +import {useAnalytics} from '#/analytics' import {BookmarkButton} from './BookmarkButton' import { PostControlButton, @@ -71,6 +71,7 @@ let PostControls = ({ viaRepost?: {uri: string; cid: string} variant?: 'compact' | 'normal' | 'large' }): React.ReactNode => { + const ax = useAnalytics() const {_} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() @@ -175,7 +176,7 @@ let PostControls = ({ feedContext, reqId, }) - logger.metric('post:clickQuotePost', { + ax.metric('post:clickQuotePost', { uri: post.uri, authorDid: post.author.did, logContext, @@ -226,7 +227,7 @@ let PostControls = ({ !replyDisabled ? () => requireAuth(() => { - logger.metric('post:clickReply', { + ax.metric('post:clickReply', { uri: post.uri, authorDid: post.author.did, logContext, diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index cb5ed17d15..b932517170 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -16,7 +16,6 @@ import {useLingui} from '@lingui/react' import {useActorStatus} from '#/lib/actor-status' import {getModerationCauseKey} from '#/lib/moderation' -import {type LogEvents} from '#/lib/statsig/statsig' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' @@ -47,6 +46,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {type Metrics} from '#/analytics' import type * as bsky from '#/types/bsky' export function Default({ @@ -461,8 +461,8 @@ export function DescriptionPlaceholder({ export type FollowButtonProps = { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts - logContext: LogEvents['profile:follow']['logContext'] & - LogEvents['profile:unfollow']['logContext'] + logContext: Metrics['profile:follow']['logContext'] & + Metrics['profile:unfollow']['logContext'] colorInverted?: boolean onFollow?: () => void withIcon?: boolean diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index fe4e29d98a..78ac868b28 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -10,8 +10,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' -import {logEvent} from '#/lib/statsig/statsig' -import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorSearch} from '#/state/queries/actor-search' import {usePreferencesQuery} from '#/state/queries/preferences' @@ -36,6 +34,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {ProgressGuideTask} from './Task' @@ -67,6 +66,7 @@ export function FollowDialog({ guide: Follow10ProgressGuide showArrow?: boolean }) { + const ax = useAnalytics() const {_} = useLingui() const control = Dialog.useDialogControl() const {gtPhone} = useBreakpoints() @@ -78,7 +78,7 @@ export function FollowDialog({ label={_(msg`Find people to follow`)} onPress={() => { control.open() - logEvent('progressGuide:followDialog:open', {}) + ax.metric('progressGuide:followDialog:open', {}) }} size={gtPhone ? 'small' : 'large'} color="primary"> @@ -118,6 +118,7 @@ let lastSearchText = '' function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const {_} = useLingui() + const ax = useAnalytics() const interestsDisplayNames = useInterestsDisplayNames() const {data: preferences} = usePreferencesQuery() const personalizedInterests = preferences?.interests?.tags @@ -271,17 +272,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const position = itemsRef.current.findIndex( i => i.type === 'profile' && i.profile.did === item.profile.did, ) - logger.metric( - 'suggestedUser:seen', - { - logContext: 'ProgressGuide', - recId: undefined, - position: position !== -1 ? position : 0, - suggestedDid: item.profile.did, - category: selectedInterestRef.current, - }, - {statsig: true}, - ) + ax.metric('suggestedUser:seen', { + logContext: 'ProgressGuide', + recId: undefined, + position: position !== -1 ? position : 0, + suggestedDid: item.profile.did, + category: selectedInterestRef.current, + }) } } } diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index 4c9bcc510d..e0c894a769 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -19,6 +19,7 @@ import {FloppyDisk_Stroke2_Corner0_Rounded as FloppyDiskIcon} from '#/components import {Loader} from '#/components/Loader' import {QrCode} from '#/components/StarterPack/QrCode' import * as Toast from '#/components/Toast' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' import * as bsky from '#/types/bsky' @@ -32,6 +33,7 @@ export function QrCodeDialog({ control: DialogControlProps }) { const {_} = useLingui() + const ax = useAnalytics() const {gtMobile} = useBreakpoints() const [isSaveProcessing, setIsSaveProcessing] = useState(false) const [isCopyProcessing, setIsCopyProcessing] = useState(false) @@ -104,7 +106,7 @@ export function QrCodeDialog({ link.click() } - logger.metric('starterPack:share', { + ax.metric('starterPack:share', { starterPack: starterPack.uri, shareType: 'qrcode', qrShareType: 'save', @@ -129,7 +131,7 @@ export function QrCodeDialog({ navigator.clipboard.write([item]) }) - logger.metric('starterPack:share', { + ax.metric('starterPack:share', { starterPack: starterPack.uri, shareType: 'qrcode', qrShareType: 'copy', @@ -145,7 +147,7 @@ export function QrCodeDialog({ control.close(() => { Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( () => { - logger.metric('starterPack:share', { + ax.metric('starterPack:share', { starterPack: starterPack.uri, shareType: 'qrcode', qrShareType: 'share', diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 1dbbb07eef..eb9e98b3b1 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react' import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' import {shareUrl} from '#/lib/sharing' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' -import {logger} from '#/logger' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {type DialogControlProps} from '#/components/Dialog' @@ -17,6 +16,7 @@ import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/ico import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' interface Props { @@ -46,6 +46,7 @@ function ShareDialogInner({ control, }: Props) { const {_} = useLingui() + const ax = useAnalytics() const t = useTheme() const {gtMobile} = useBreakpoints() @@ -54,7 +55,7 @@ function ShareDialogInner({ const onShareLink = async () => { if (!link) return shareUrl(link) - logger.metric('starterPack:share', { + ax.metric('starterPack:share', { starterPack: starterPack.uri, shareType: 'link', }) diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx index 089092636d..b5cd15a112 100644 --- a/src/components/StarterPack/Wizard/WizardListCard.tsx +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -13,7 +13,6 @@ import {useLingui} from '@lingui/react' import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import { @@ -25,6 +24,7 @@ import {Button, ButtonText} from '#/components/Button' import * as Toggle from '#/components/forms/Toggle' import {Checkbox} from '#/components/forms/Toggle' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' function WizardListCard({ @@ -130,6 +130,7 @@ export function WizardProfileCard({ profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts }) { + const ax = useAnalytics() const {currentAccount} = useSession() // Determine the "main" profile for this starter pack - either targetDid or current account @@ -151,10 +152,10 @@ export function WizardProfileCard({ if (profile.did === targetProfileDid) return if (!included) { - logger.metric('starterPack:addUser', {}) + ax.metric('starterPack:addUser', {}) dispatch({type: 'AddProfile', profile}) } else { - logger.metric('starterPack:removeUser', {}) + ax.metric('starterPack:removeUser', {}) dispatch({type: 'RemoveProfile', profileDid: profile.did}) } } diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx index 1271a5f3ac..9b4ab08ebb 100644 --- a/src/components/WelcomeModal.tsx +++ b/src/components/WelcomeModal.tsx @@ -5,13 +5,13 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {FocusGuards, FocusScope} from 'radix-ui/internal' -import {logger} from '#/logger' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {Logo} from '#/view/icons/Logo' import {atoms as a, flatten, useBreakpoints, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') @@ -25,6 +25,7 @@ interface WelcomeModalProps { export function WelcomeModal({control}: WelcomeModalProps) { const {_} = useLingui() + const ax = useAnalytics() const {requestSwitchToAccount} = useLoggedOutViewControls() const {gtMobile} = useBreakpoints() const [isExiting, setIsExiting] = useState(false) @@ -40,23 +41,24 @@ export function WelcomeModal({control}: WelcomeModalProps) { useEffect(() => { if (control.isOpen) { - logger.metric('welcomeModal:presented', {}) + ax.metric('welcomeModal:presented', {}) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [control.isOpen]) const onPressCreateAccount = () => { - logger.metric('welcomeModal:signupClicked', {}) + ax.metric('welcomeModal:signupClicked', {}) control.close() requestSwitchToAccount({requestedAccount: 'new'}) } const onPressExplore = () => { - logger.metric('welcomeModal:exploreClicked', {}) + ax.metric('welcomeModal:exploreClicked', {}) fadeOutAndClose() } const onPressSignIn = () => { - logger.metric('welcomeModal:signinClicked', {}) + ax.metric('welcomeModal:signinClicked', {}) control.close() requestSwitchToAccount({requestedAccount: 'existing'}) } @@ -222,7 +224,7 @@ export function WelcomeModal({control}: WelcomeModalProps) { ]} hoverStyle={[a.bg_transparent]} onPress={() => { - logger.metric('welcomeModal:dismissed', {}) + ax.metric('welcomeModal:dismissed', {}) fadeOutAndClose() }} color="secondary" diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 292d041b70..8e93ee4fcc 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -17,7 +17,6 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' -import {logger} from '#/logger' import { type ThreadgateAllowUISetting, threadgateViewToAllowUISetting, @@ -36,6 +35,7 @@ import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Glo import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import * as bsky from '#/types/bsky' @@ -46,8 +46,9 @@ interface WhoCanReplyProps { } export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { - const {_} = useLingui() const t = useTheme() + const ax = useAnalytics() + const {_} = useLingui() const infoDialogControl = useDialogControl() const editDialogControl = useDialogControl() @@ -90,7 +91,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { Keyboard.dismiss() } if (isThreadAuthor) { - logger.metric('thread:click:editOwnThreadgate', {}) + ax.metric('thread:click:editOwnThreadgate', {}) // wait on prefetch if it manages to resolve in under 200ms // otherwise, proceed immediately and show the spinner -sfn @@ -101,7 +102,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { editDialogControl.open() }) } else { - logger.metric('thread:click:viewSomeoneElsesThreadgate', {}) + ax.metric('thread:click:viewSomeoneElsesThreadgate', {}) infoDialogControl.open() } diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx index a8a5df39f2..cb0cae9fcc 100644 --- a/src/components/activity-notifications/SubscribeProfileDialog.tsx +++ b/src/components/activity-notifications/SubscribeProfileDialog.tsx @@ -17,13 +17,11 @@ import { import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {cleanError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' import {updateProfileShadow} from '#/state/cache/profile-shadow' import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' import {useAgent} from '#/state/session' import * as Toast from '#/view/com/util/Toast' -import {platform, useTheme, web} from '#/alf' -import {atoms as a} from '#/alf' +import {atoms as a, platform, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import { Button, @@ -36,6 +34,7 @@ import * as Toggle from '#/components/forms/Toggle' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' @@ -71,6 +70,7 @@ function DialogInner({ moderationOpts: ModerationOpts includeProfile?: boolean }) { + const ax = useAnalytics() const {_} = useLingui() const t = useTheme() const agent = useAgent() @@ -133,7 +133,7 @@ function DialogInner({ }) if (!activitySubscription.post && !activitySubscription.reply) { - logger.metric('activitySubscription:disable', {}) + ax.metric('activitySubscription:disable', {}) Toast.show( _( msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, @@ -160,7 +160,7 @@ function DialogInner({ }, ) } else { - logger.metric('activitySubscription:enable', { + ax.metric('activitySubscription:enable', { setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', }) if (!initialState.post && !initialState.reply) { @@ -177,7 +177,7 @@ function DialogInner({ }) }, onError: err => { - logger.error('Could not save activity subscription', {message: err}) + ax.logger.error('Could not save activity subscription', {message: err}) }, }) diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index 41659d3edb..7d7b5ade06 100644 --- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -20,8 +20,9 @@ import {Divider} from '#/components/Divider' import {createStaticClick, InlineLinkText} from '#/components/Link' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' -import {logger, useAgeAssurance} from '#/ageAssurance' +import {useAgeAssurance} from '#/ageAssurance' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import {useDeviceGeolocationApi} from '#/geolocation' @@ -41,6 +42,7 @@ export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { function Inner({style}: ViewStyleProp & {}) { const t = useTheme() const {_, i18n} = useLingui() + const ax = useAnalytics() const control = useDialogControl() const appealControl = Dialog.useDialogControl() const locationControl = Dialog.useDialogControl() @@ -138,7 +140,7 @@ function Inner({style}: ViewStyleProp & {}) { label={_(msg`Contact our moderation team`)} {...createStaticClick(() => { appealControl.open() - logger.metric('ageAssurance:appealDialogOpen', {}) + ax.metric('ageAssurance:appealDialogOpen', {}) })}> contact our moderation team {' '} @@ -167,7 +169,7 @@ function Inner({style}: ViewStyleProp & {}) { color={hasInitiated ? 'secondary' : 'primary'} onPress={() => { control.open() - logger.metric('ageAssurance:initDialogOpen', { + ax.metric('ageAssurance:initDialogOpen', { hasInitiatedPreviously: hasInitiated, }) }}> diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx index 1ef29dfbbb..426924b637 100644 --- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx +++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx @@ -10,7 +10,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/ import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' -import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' export function AgeAssuranceAdmonition({ children, @@ -40,6 +40,7 @@ function Inner({ }) { const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() return ( <> @@ -92,7 +93,7 @@ function Inner({ to={'/settings/account'} style={[a.text_sm, a.leading_snug, a.font_semi_bold]} onPress={() => { - logger.metric('ageAssurance:navigateToSettings', {}) + ax.metric('ageAssurance:navigateToSettings', {}) }}> account settings. diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx index d8330a94c3..c7f4c170e1 100644 --- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -15,6 +15,7 @@ import * as Dialog from '#/components/Dialog' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' export function AgeAssuranceAppealDialog({ control, @@ -37,6 +38,7 @@ export function AgeAssuranceAppealDialog({ function Inner({control}: {control: Dialog.DialogControlProps}) { const {_} = useLingui() + const ax = useAnalytics() const {currentAccount} = useSession() const {gtPhone} = useBreakpoints() const agent = useAgent() @@ -46,7 +48,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) { const {mutate, isPending} = useMutation({ mutationFn: async () => { - logger.metric('ageAssurance:appealDialogSubmit', {}) + ax.metric('ageAssurance:appealDialogSubmit', {}) await agent.createModerationReport( { diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx index aa1d527f62..b32a2457b3 100644 --- a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx +++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx @@ -12,7 +12,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' -import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' export function useInternalState() { const aa = useAgeAssurance() @@ -42,6 +42,7 @@ export function useInternalState() { export function AgeAssuranceDismissibleFeedBanner() { const t = useTheme() + const ax = useAnalytics() const {_} = useLingui() const {visible, close} = useInternalState() const copy = useAgeAssuranceCopy() @@ -66,7 +67,7 @@ export function AgeAssuranceDismissibleFeedBanner() { to="/settings/account" onPress={() => { close() - logger.metric('ageAssurance:navigateToSettings', {}) + ax.metric('ageAssurance:navigateToSettings', {}) }} style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> { close() - logger.metric('ageAssurance:dismissFeedBanner', {}) + ax.metric('ageAssurance:dismissFeedBanner', {}) }} style={[ a.absolute, diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx index 14c7ed300f..fcfdf18017 100644 --- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx +++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx @@ -10,10 +10,11 @@ import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {useAgeAssurance} from '#/ageAssurance' -import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { const {_} = useLingui() + const ax = useAnalytics() const aa = useAgeAssurance() const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) const copy = useAgeAssuranceCopy() @@ -45,7 +46,7 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { completed: true, data: undefined, }) - logger.metric('ageAssurance:dismissSettingsNotice', {}) + ax.metric('ageAssurance:dismissSettingsNotice', {}) }} style={[ a.absolute, diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index 32f6b0044a..8414b5fbd8 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -19,8 +19,7 @@ import {useSession} from '#/state/session' import {atoms as a, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' -import {urls} from '#/components/ageAssurance/const' -import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const' +import {KWS_SUPPORTED_LANGS, urls} from '#/components/ageAssurance/const' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Divider} from '#/components/Divider' @@ -30,9 +29,9 @@ import {LanguageSelect} from '#/components/LanguageSelect' import {SimpleInlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {logger} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance' import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' +import {useAnalytics} from '#/analytics' export {useDialogControl} from '#/components/Dialog/context' @@ -64,6 +63,7 @@ export function AgeAssuranceInitDialog({ function Inner() { const {_} = useLingui() + const ax = useAnalytics() const {currentAccount} = useSession() const langPrefs = useLanguagePrefs() const cleanError = useCleanError() @@ -116,7 +116,7 @@ function Inner() { const onSubmit = async () => { setLanguageError(false) - logger.metric('ageAssurance:initDialogSubmit', {}) + ax.metric('ageAssurance:initDialogSubmit', {}) try { const {status} = runEmailValidation() @@ -143,7 +143,7 @@ function Inner() { error = _( msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, ) - logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) + ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) } else if (e.error === 'DidTooLong') { error = ( <> @@ -159,14 +159,14 @@ function Inner() { ) - logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) + ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) } else { - logger.metric('ageAssurance:initDialogError', {code: 'other'}) + ax.metric('ageAssurance:initDialogError', {code: 'other'}) } } else { const {clean, raw} = cleanError(e) error = clean || raw || error - logger.metric('ageAssurance:initDialogError', {code: 'other'}) + ax.metric('ageAssurance:initDialogError', {code: 'other'}) } setError(error) diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx index 0cbf6bed04..fc8dfeef4a 100644 --- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -16,7 +16,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {refetchAgeAssuranceServerState} from '#/ageAssurance' -import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' export type AgeAssuranceRedirectDialogState = { @@ -81,6 +81,7 @@ export function AgeAssuranceRedirectDialog() { export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { const t = useTheme() + const ax = useAnalytics() const {_} = useLingui() const agent = useAgent() const polling = useRef(false) @@ -94,7 +95,7 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { polling.current = true - logger.metric('ageAssurance:redirectDialogOpen', {}) + ax.metric('ageAssurance:redirectDialogOpen', {}) wait( 3e3, @@ -125,18 +126,18 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { setSuccess(true) - logger.metric('ageAssurance:redirectDialogSuccess', {}) + ax.metric('ageAssurance:redirectDialogSuccess', {}) }) .catch(() => { if (unmounted.current) return setError(true) - logger.metric('ageAssurance:redirectDialogFail', {}) + ax.metric('ageAssurance:redirectDialogFail', {}) }) return () => { unmounted.current = true } - }, [agent, control]) + }, [ax, agent, control]) if (success) { return ( diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx index 9f9c4c4d5b..8c77e8e43d 100644 --- a/src/components/ageAssurance/AgeRestrictedScreen.tsx +++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx @@ -13,7 +13,7 @@ import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' -import {logger} from '#/ageAssurance' +import {useAnalytics} from '#/analytics' export function AgeRestrictedScreen({ children, @@ -27,6 +27,7 @@ export function AgeRestrictedScreen({ rightHeaderSlot?: React.ReactNode }) { const {_} = useLingui() + const ax = useAnalytics() const copy = useAgeAssuranceCopy() const aa = useAgeAssurance() @@ -74,7 +75,7 @@ export function AgeRestrictedScreen({ variant="solid" color="primary" onPress={() => { - logger.metric('ageAssurance:navigateToSettings', {}) + ax.metric('ageAssurance:navigateToSettings', {}) }}> Go to account settings diff --git a/src/components/contacts/FindContactsBannerNUX.tsx b/src/components/contacts/FindContactsBannerNUX.tsx index 768fee519b..577474a3a4 100644 --- a/src/components/contacts/FindContactsBannerNUX.tsx +++ b/src/components/contacts/FindContactsBannerNUX.tsx @@ -6,12 +6,12 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' -import {logger} from '#/logger' import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' import {Link} from '../Link' import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist' @@ -19,6 +19,7 @@ import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allow export function FindContactsBannerNUX() { const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() const {visible, close} = useInternalState() if (!visible) return null @@ -30,7 +31,7 @@ export function FindContactsBannerNUX() { to={{screen: 'FindContactsFlow'}} label={_(msg`Import contacts to find your friends`)} onPress={() => { - logger.metric('contacts:nux:bannerPressed', {}) + ax.metric('contacts:nux:bannerPressed', {}) }} style={[ a.w_full, @@ -84,6 +85,7 @@ export function FindContactsBannerNUX() { ) } function useInternalState() { + const ax = useAnalytics() const {nux} = useNux(Nux.FindContactsDismissibleBanner) const {mutate: save, variables} = useSaveNux() const hidden = !!variables @@ -103,7 +105,7 @@ function useInternalState() { completed: true, data: undefined, }) - logger.metric('contacts:nux:bannerDismissed', {}) + ax.metric('contacts:nux:bannerDismissed', {}) } return {visible, close} diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 5df967a8a5..909be81abc 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -28,6 +28,7 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import { contactsWithPhoneNumbersOnly, filterMatchedNumbers, @@ -51,6 +52,7 @@ export function GetContacts({ context: 'Onboarding' | 'Standalone' }) { const {_} = useLingui() + const ax = useAnalytics() const agent = useAgent() const insets = useSafeAreaInsets() const gutters = useGutters([0, 'wide']) @@ -100,16 +102,16 @@ export function GetContacts({ }, onSuccess: (result, contacts) => { if (context === 'Onboarding') { - logger.metric('onboarding:contacts:contactsShared', {}) + ax.metric('onboarding:contacts:contactsShared', {}) } if (result.matches.length > 0) { - logger.metric('contacts:import:success', { + ax.metric('contacts:import:success', { contactCount: contacts.length, matchCount: result.matches.length, entryPoint: context, }) } else { - logger.metric('contacts:import:failure', { + ax.metric('contacts:import:failure', { reason: 'noValidNumbers', entryPoint: context, }) @@ -134,7 +136,7 @@ export function GetContacts({ }) }, onError: err => { - logger.metric('contacts:import:failure', { + ax.metric('contacts:import:failure', { reason: isNetworkError(err) ? 'networkError' : 'unknown', entryPoint: context, }) @@ -180,7 +182,7 @@ export function GetContacts({ permissions = await Contacts.requestPermissionsAsync() } - logger.metric('contacts:permission:request', { + ax.metric('contacts:permission:request', { status: permissions.granted ? 'granted' : 'denied', accessLevelIOS: ios(permissions.accessPrivileges), }) diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index 25bffaf144..8eec5742e8 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -31,6 +31,7 @@ import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {useGeolocation} from '#/geolocation' import {isFindContactsFeatureEnabled} from '../country-allowlist' import { @@ -52,6 +53,7 @@ export function PhoneInput({ onSkip: () => void }) { const {_} = useLingui() + const ax = useAnalytics() const t = useTheme() const agent = useAgent() const location = useGeolocation() @@ -85,7 +87,7 @@ export function PhoneInput({ payload: {phoneCountryCode, phoneNumber}, }) - logger.metric('contacts:phone:phoneEntered', {entryPoint: context}) + ax.metric('contacts:phone:phoneEntered', {entryPoint: context}) }, onMutate: () => { Keyboard.dismiss() diff --git a/src/components/contacts/screens/VerifyNumber.tsx b/src/components/contacts/screens/VerifyNumber.tsx index ec676ad724..c316c66af7 100644 --- a/src/components/contacts/screens/VerifyNumber.tsx +++ b/src/components/contacts/screens/VerifyNumber.tsx @@ -23,6 +23,7 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import {OTPInput} from '../components/OTPInput' import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' import {type Action, type State, useOnPressBackButton} from '../state' @@ -40,6 +41,7 @@ export function VerifyNumber({ }) { const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() const agent = useAgent() const gutters = useGutters([0, 'wide']) @@ -83,7 +85,7 @@ export function VerifyNumber({ }) }, 1000) - logger.metric('contacts:phone:phoneVerified', {entryPoint: context}) + ax.metric('contacts:phone:phoneVerified', {entryPoint: context}) }, onMutate: () => setError(null), onError: err => { diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index d92cb88b1e..b9aec3ed95 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -39,6 +39,7 @@ import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' import {InviteInfo} from '../components/InviteInfo' import {type Action, type Contact, type Match, type State} from '../state' @@ -83,6 +84,7 @@ export function ViewMatches({ }) { const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() const gutter = useGutters([0, 'wide']) const moderationOpts = useModerationOpts() const queryClient = useQueryClient() @@ -109,9 +111,9 @@ export function ViewMatches({ const cumulativeFollowCount = useRef(0) const onFollow = useCallback(() => { - logger.metric('contacts:matches:follow', {entryPoint: context}) + ax.metric('contacts:matches:follow', {entryPoint: context}) cumulativeFollowCount.current += 1 - }, [context]) + }, [ax, context]) const {mutate: followAll, isPending: isFollowingAll} = useMutation({ mutationFn: async () => { @@ -132,7 +134,7 @@ export function ViewMatches({ return followableDids }, onMutate: () => - logger.metric('contacts:matches:followAll', { + ax.metric('contacts:matches:followAll', { followCount: followableDids.length, entryPoint: context, }), @@ -218,7 +220,7 @@ export function ViewMatches({ await agent.app.bsky.contact.dismissMatch({subject: did}) }, onMutate: did => { - logger.metric('contacts:matches:dismiss', {entryPoint: context}) + ax.metric('contacts:matches:dismiss', {entryPoint: context}) dispatch({type: 'DISMISS_MATCH', payload: {did}}) }, onSuccess: (_res, did) => { @@ -392,7 +394,7 @@ export function ViewMatches({ label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} onPress={() => { if (context === 'Onboarding') { - logger.metric('onboarding:contacts:nextPressed', { + ax.metric('onboarding:contacts:nextPressed', { matchCount: allMatches.length, followCount: cumulativeFollowCount.current, dismissedMatchCount: state.dismissedMatches.length, @@ -516,6 +518,7 @@ function ContactItem({ const gutter = useGutters([0, 'wide']) const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() const {currentAccount} = useSession() const name = contact.name ?? contact.firstName ?? contact.lastName @@ -564,7 +567,7 @@ function ContactItem({ color="secondary" size="small" onPress={async () => { - logger.metric('contacts:matches:invite', { + ax.metric('contacts:matches:invite', { entryPoint: context, }) try { diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 259395a8c9..23fd2f38e6 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -11,7 +11,6 @@ import {Image} from 'expo-image' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import { type Gif, @@ -30,6 +29,7 @@ import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' export function GifSelectDialog({ @@ -280,14 +280,15 @@ export function GifPreview({ gif: Gif onSelectGif: (gif: Gif) => void }) { + const ax = useAnalytics() const {gtTablet} = useBreakpoints() const {_} = useLingui() const t = useTheme() const onPress = useCallback(() => { - logEvent('composer:gif:select', {}) + ax.metric('composer:gif:select', {}) onSelectGif(gif) - }, [onSelectGif, gif]) + }, [ax, onSelectGif, gif]) return (