[APP-1782] Analytics migration (#9734)

* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

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

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

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

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests
This commit is contained in:
Eric Bailey
2026-01-22 15:33:45 -06:00
committed by GitHub
parent 8614e5e105
commit 25a1ed1209
190 changed files with 2358 additions and 1689 deletions
+6 -5
View File
@@ -6,11 +6,12 @@ import {logger} from '#/logger'
import {type SessionAccount, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {useAnalytics} from '#/analytics'
import {type Metrics} from '#/analytics/metrics'
import {IS_WEB} from '#/env'
import {logEvent} from '../statsig/statsig'
import {type LogEvents} from '../statsig/statsig'
export function useAccountSwitcher() {
const ax = useAnalytics()
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const {resumeSession} = useSessionApi()
@@ -19,7 +20,7 @@ export function useAccountSwitcher() {
const onPressSwitchAccount = useCallback(
async (
account: SessionAccount,
logContext: LogEvents['account:loggedIn']['logContext'],
logContext: Metrics['account:loggedIn']['logContext'],
) => {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
@@ -37,7 +38,7 @@ export function useAccountSwitcher() {
history.pushState(null, '', '/')
}
await resumeSession(account, true)
logEvent('account:loggedIn', {logContext, withPassword: false})
ax.metric('account:loggedIn', {logContext, withPassword: false})
Toast.show(_(msg`Signed in as @${account.handle}`))
} else {
requestSwitchToAccount({requestedAccount: account.did})
@@ -59,7 +60,7 @@ export function useAccountSwitcher() {
setPendingDid(null)
}
},
[_, resumeSession, requestSwitchToAccount, pendingDid],
[_, ax, resumeSession, requestSwitchToAccount, pendingDid],
)
return {onPressSwitchAccount, pendingDid}
-15
View File
@@ -1,15 +0,0 @@
import {useEffect, useState} from 'react'
import {AppState} from 'react-native'
export function useAppState() {
const [state, setState] = useState(AppState.currentState)
useEffect(() => {
const sub = AppState.addEventListener('change', nextAppState => {
setState(nextAppState)
})
return () => sub.remove()
}, [])
return state
}
-20
View File
@@ -1,20 +0,0 @@
import {useCallback} from 'react'
export enum OnceKey {
PreferencesThread = 'preferences:thread',
}
const called: Record<OnceKey, boolean> = {
[OnceKey.PreferencesThread]: false,
}
export function useCallOnce(key: OnceKey) {
return useCallback(
(cb: () => void) => {
if (called[key] === true) return
called[key] = true
cb()
},
[key],
)
}
+4 -2
View File
@@ -5,10 +5,10 @@ import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
@@ -22,6 +22,7 @@ let previousIntentUrl = ''
export function useIntentHandler() {
const incomingUrl = Linking.useLinkingURL()
const ax = useAnalytics()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
const {currentAccount} = useSession()
@@ -36,7 +37,7 @@ export function useIntentHandler() {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logger.metric('deepLink:referrerReceived', {
ax.metric('deepLink:referrerReceived', {
to: url,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
@@ -95,6 +96,7 @@ export function useIntentHandler() {
}
}, [
incomingUrl,
ax,
composeIntent,
verifyEmailIntent,
currentAccount,
+3 -3
View File
@@ -1,8 +1,8 @@
import {useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig'
import {useAnalytics} from '#/analytics'
export function useIsBskyTeam() {
const gate = useGate()
return useMemo(() => gate('is_bsky_team_member'), [gate])
const ax = useAnalytics()
return useMemo(() => ax.features.enabled(ax.features.IsBskyTeam), [ax])
}
+16 -12
View File
@@ -16,6 +16,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {resetToTab} from '#/Navigation'
import {router} from '#/routes'
@@ -75,6 +76,8 @@ let storedAccountSwitchPayload: NotificationPayload
let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() {
const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
@@ -190,7 +193,7 @@ export function useNotificationsHandler() {
if (!payload) return
if (payload.reason === 'chat-message') {
notyLogger.debug(`useNotificationsHandler: handling chat message`, {
logger.debug(`useNotificationsHandler: handling chat message`, {
payload,
})
@@ -250,7 +253,7 @@ export function useNotificationsHandler() {
const [screen, params] = router.matchPath(url)
// @ts-expect-error router is not typed :/ -sfn
navigation.navigate('HomeTab', {screen, params})
notyLogger.debug(`useNotificationsHandler: navigate`, {
logger.debug(`useNotificationsHandler: navigate`, {
screen,
params,
})
@@ -264,7 +267,7 @@ export function useNotificationsHandler() {
if (!payload) return DEFAULT_HANDLER_OPTIONS
notyLogger.debug('useNotificationsHandler: incoming', {e, payload})
logger.debug('useNotificationsHandler: incoming', {e, payload})
if (
payload.reason === 'chat-message' &&
@@ -290,7 +293,7 @@ export function useNotificationsHandler() {
if (e.notification.date === lastHandledNotificationDateDedupe) return
lastHandledNotificationDateDedupe = e.notification.date
notyLogger.debug('useNotificationsHandler: response received', {
logger.debug('useNotificationsHandler: response received', {
actionIdentifier: e.actionIdentifier,
})
@@ -301,15 +304,14 @@ export function useNotificationsHandler() {
const payload = getNotificationPayload(e.notification)
if (payload) {
notyLogger.debug(
logger.debug(
'User pressed a notification, opening notifications tab',
{},
)
notyLogger.metric(
'notifications:openApp',
{reason: payload.reason, causedBoot: false},
{statsig: false},
)
ax.metric('notifications:openApp', {
reason: payload.reason,
causedBoot: false,
})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
@@ -322,7 +324,7 @@ export function useNotificationsHandler() {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
notyLogger.debug('Notifications: handleNotification', {
logger.debug('Notifications: handleNotification', {
content: e.notification.request.content,
payload: payload,
})
@@ -330,7 +332,7 @@ export function useNotificationsHandler() {
handleNotification(payload)
Notifications.dismissAllNotificationsAsync()
} else {
notyLogger.error('useNotificationsHandler: received no payload', {
logger.error('useNotificationsHandler: received no payload', {
identifier: e.notification.request.identifier,
})
}
@@ -350,6 +352,8 @@ export function useNotificationsHandler() {
responseReceivedListener.remove()
}
}, [
ax,
logger,
queryClient,
currentAccount,
currentConvoId,
+2 -3
View File
@@ -12,8 +12,7 @@ import {
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS} from '#/env'
import {IS_TESTFLIGHT} from '#/env'
import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
@@ -170,7 +169,7 @@ export function useOTAUpdates() {
return
}
// We use this setTimeout to allow Statsig to initialize before we check for an update
// We use this setTimeout to allow analytics to initialize before we check for an update
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
// immediately.
+4 -3
View File
@@ -2,7 +2,6 @@ import {useCallback} from 'react'
import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import {logEvent} from '#/lib/statsig/statsig'
import {
createBskyAppAbsoluteUrl,
createProxiedUrl,
@@ -16,9 +15,11 @@ import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {useTheme} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
export function useOpenLink() {
const ax = useAnalytics()
const enabled = useInAppBrowser()
const t = useTheme()
const dialogContext = useDialogContext()
@@ -31,7 +32,7 @@ export function useOpenLink() {
}
if (!isBskyAppUrl(url)) {
logEvent('link:clicked', {
ax.metric('link:clicked', {
domain: toNiceDomain(url),
url,
})
@@ -72,7 +73,7 @@ export function useOpenLink() {
}
Linking.openURL(url)
},
[enabled, inAppBrowserConsentControl, t, dialogContext],
[ax, enabled, inAppBrowserConsentControl, t, dialogContext],
)
return openLink
+9 -13
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef} from 'react'
import {type AppBskyFeedDefs} from '@atproto/api'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {type Metrics, useAnalytics} from '#/analytics'
/**
* Hook that returns a callback to track post:view events.
@@ -12,8 +11,9 @@ import {type MetricEvents} from '#/logger/metrics'
* @returns A callback that accepts a post and logs the view event
*/
export function usePostViewTracking(
logContext: MetricEvents['post:view']['logContext'],
logContext: Metrics['post:view']['logContext'],
) {
const ax = useAnalytics()
const seenUrisRef = useRef(new Set<string>())
const trackPostView = useCallback(
@@ -21,17 +21,13 @@ export function usePostViewTracking(
if (seenUrisRef.current.has(post.uri)) return
seenUrisRef.current.add(post.uri)
logger.metric(
'post:view',
{
uri: post.uri,
authorDid: post.author.did,
logContext,
},
{statsig: false},
)
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
logContext,
})
},
[logContext],
[ax, logContext],
)
return trackPostView