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 (