[APP-1782] Analytics migration (#9734)
* WIP * Clean up growthbook code, integrate into init and sessions * Move everything out of React * Add metrics client * Move to separate file * Shared metadata cache * Ensure we update metadata when session ID changes * Ensure userMetadata is cleared when logging out * WIP revamp * Integrate feature gates into analytics context * Clean up old code * Fix useMeta util * Some comments and cleanup * Add logger to base analytics context * Refactor current route handling * Rip out LogEvent from navigation * Update tracking endpoint * Migrate toClout * Clear out statsig client * Add todo, reset logger readme * Ope fix statsig noop * Refactor logging in feed-feedback, add debug logging to metrics client * Remove LogEvents alias for Metrics * Prefer root package export * Remove Metrics alias from logger * [APP-1782] Migrate to new analytics APIs (#9735) * Migrate logEvent to useAnalytics * Migrate logger.metric to useAnalytics * Migrate tricky spot, fix types * Migrate remaining tricky spot * Missed one * Remove metric() from logger * Migrate useGate to useAnalytics * Remove all other StatSig mentions * Update event payload * Update logger tests * Mock expo method * Fix session ID bug * Add session ID test * Add test for metrics client * Clarify intent * Clean up core analytics file * Clean up the call once utils * Fix TODO * Fix TODO * Fix TODO * Fix TODO * Fix TODO * Remove debug code * Fix navigation context * OK nav context is not working, todo * Checkpoint: works but feels hacky * Fix navigation context issue * Improve feature API * Improve metric logging * Update logger tests
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
|
||||
export const getCurrentState = () => AppState.currentState
|
||||
|
||||
export function onAppStateChange(cb: (state: AppStateStatus) => void) {
|
||||
let prev = AppState.currentState
|
||||
return AppState.addEventListener('change', next => {
|
||||
if (next === prev) return
|
||||
prev = next
|
||||
cb(next)
|
||||
})
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
const [state, setState] = useState(AppState.currentState)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = onAppStateChange(next => {
|
||||
setState(next)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import {logger} from '#/logger'
|
||||
import {type SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type Metrics} from '#/analytics/metrics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
import {type LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const ax = useAnalytics()
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {resumeSession} = useSessionApi()
|
||||
@@ -19,7 +20,7 @@ export function useAccountSwitcher() {
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
logContext: Metrics['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
@@ -37,7 +38,7 @@ export function useAccountSwitcher() {
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await resumeSession(account, true)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
ax.metric('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} else {
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
@@ -59,7 +60,7 @@ export function useAccountSwitcher() {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[_, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
[_, ax, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
|
||||
export function useAppState() {
|
||||
const [state, setState] = useState(AppState.currentState)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', nextAppState => {
|
||||
setState(nextAppState)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
export enum OnceKey {
|
||||
PreferencesThread = 'preferences:thread',
|
||||
}
|
||||
|
||||
const called: Record<OnceKey, boolean> = {
|
||||
[OnceKey.PreferencesThread]: false,
|
||||
}
|
||||
|
||||
export function useCallOnce(key: OnceKey) {
|
||||
return useCallback(
|
||||
(cb: () => void) => {
|
||||
if (called[key] === true) return
|
||||
called[key] = true
|
||||
cb()
|
||||
},
|
||||
[key],
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import * as WebBrowser from 'expo-web-browser'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {logger} from '#/logger'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
|
||||
@@ -22,6 +22,7 @@ let previousIntentUrl = ''
|
||||
|
||||
export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useLinkingURL()
|
||||
const ax = useAnalytics()
|
||||
const composeIntent = useComposeIntent()
|
||||
const verifyEmailIntent = useVerifyEmailIntent()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -36,7 +37,7 @@ export function useIntentHandler() {
|
||||
|
||||
const referrerInfo = Referrer.getReferrerInfo()
|
||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||
logger.metric('deepLink:referrerReceived', {
|
||||
ax.metric('deepLink:referrerReceived', {
|
||||
to: url,
|
||||
referrer: referrerInfo?.referrer,
|
||||
hostname: referrerInfo?.hostname,
|
||||
@@ -95,6 +96,7 @@ export function useIntentHandler() {
|
||||
}
|
||||
}, [
|
||||
incomingUrl,
|
||||
ax,
|
||||
composeIntent,
|
||||
verifyEmailIntent,
|
||||
currentAccount,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function useIsBskyTeam() {
|
||||
const gate = useGate()
|
||||
return useMemo(() => gate('is_bsky_team_member'), [gate])
|
||||
const ax = useAnalytics()
|
||||
return useMemo(() => ax.features.enabled(ax.features.IsBskyTeam), [ax])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {resetToTab} from '#/Navigation'
|
||||
import {router} from '#/routes'
|
||||
@@ -75,6 +76,8 @@ let storedAccountSwitchPayload: NotificationPayload
|
||||
let lastHandledNotificationDateDedupe = 0
|
||||
|
||||
export function useNotificationsHandler() {
|
||||
const ax = useAnalytics()
|
||||
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
|
||||
const queryClient = useQueryClient()
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
@@ -190,7 +193,7 @@ export function useNotificationsHandler() {
|
||||
if (!payload) return
|
||||
|
||||
if (payload.reason === 'chat-message') {
|
||||
notyLogger.debug(`useNotificationsHandler: handling chat message`, {
|
||||
logger.debug(`useNotificationsHandler: handling chat message`, {
|
||||
payload,
|
||||
})
|
||||
|
||||
@@ -250,7 +253,7 @@ export function useNotificationsHandler() {
|
||||
const [screen, params] = router.matchPath(url)
|
||||
// @ts-expect-error router is not typed :/ -sfn
|
||||
navigation.navigate('HomeTab', {screen, params})
|
||||
notyLogger.debug(`useNotificationsHandler: navigate`, {
|
||||
logger.debug(`useNotificationsHandler: navigate`, {
|
||||
screen,
|
||||
params,
|
||||
})
|
||||
@@ -264,7 +267,7 @@ export function useNotificationsHandler() {
|
||||
|
||||
if (!payload) return DEFAULT_HANDLER_OPTIONS
|
||||
|
||||
notyLogger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||
|
||||
if (
|
||||
payload.reason === 'chat-message' &&
|
||||
@@ -290,7 +293,7 @@ export function useNotificationsHandler() {
|
||||
if (e.notification.date === lastHandledNotificationDateDedupe) return
|
||||
lastHandledNotificationDateDedupe = e.notification.date
|
||||
|
||||
notyLogger.debug('useNotificationsHandler: response received', {
|
||||
logger.debug('useNotificationsHandler: response received', {
|
||||
actionIdentifier: e.actionIdentifier,
|
||||
})
|
||||
|
||||
@@ -301,15 +304,14 @@ export function useNotificationsHandler() {
|
||||
const payload = getNotificationPayload(e.notification)
|
||||
|
||||
if (payload) {
|
||||
notyLogger.debug(
|
||||
logger.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
)
|
||||
notyLogger.metric(
|
||||
'notifications:openApp',
|
||||
{reason: payload.reason, causedBoot: false},
|
||||
{statsig: false},
|
||||
)
|
||||
ax.metric('notifications:openApp', {
|
||||
reason: payload.reason,
|
||||
causedBoot: false,
|
||||
})
|
||||
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
|
||||
@@ -322,7 +324,7 @@ export function useNotificationsHandler() {
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
|
||||
}
|
||||
|
||||
notyLogger.debug('Notifications: handleNotification', {
|
||||
logger.debug('Notifications: handleNotification', {
|
||||
content: e.notification.request.content,
|
||||
payload: payload,
|
||||
})
|
||||
@@ -330,7 +332,7 @@ export function useNotificationsHandler() {
|
||||
handleNotification(payload)
|
||||
Notifications.dismissAllNotificationsAsync()
|
||||
} else {
|
||||
notyLogger.error('useNotificationsHandler: received no payload', {
|
||||
logger.error('useNotificationsHandler: received no payload', {
|
||||
identifier: e.notification.request.identifier,
|
||||
})
|
||||
}
|
||||
@@ -350,6 +352,8 @@ export function useNotificationsHandler() {
|
||||
responseReceivedListener.remove()
|
||||
}
|
||||
}, [
|
||||
ax,
|
||||
logger,
|
||||
queryClient,
|
||||
currentAccount,
|
||||
currentConvoId,
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {IS_TESTFLIGHT} from '#/env'
|
||||
import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
|
||||
@@ -170,7 +169,7 @@ export function useOTAUpdates() {
|
||||
return
|
||||
}
|
||||
|
||||
// We use this setTimeout to allow Statsig to initialize before we check for an update
|
||||
// We use this setTimeout to allow analytics to initialize before we check for an update
|
||||
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
||||
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
||||
// immediately.
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useCallback} from 'react'
|
||||
import {Linking} from 'react-native'
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
createBskyAppAbsoluteUrl,
|
||||
createProxiedUrl,
|
||||
@@ -16,9 +15,11 @@ import {useInAppBrowser} from '#/state/preferences/in-app-browser'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
|
||||
export function useOpenLink() {
|
||||
const ax = useAnalytics()
|
||||
const enabled = useInAppBrowser()
|
||||
const t = useTheme()
|
||||
const dialogContext = useDialogContext()
|
||||
@@ -31,7 +32,7 @@ export function useOpenLink() {
|
||||
}
|
||||
|
||||
if (!isBskyAppUrl(url)) {
|
||||
logEvent('link:clicked', {
|
||||
ax.metric('link:clicked', {
|
||||
domain: toNiceDomain(url),
|
||||
url,
|
||||
})
|
||||
@@ -72,7 +73,7 @@ export function useOpenLink() {
|
||||
}
|
||||
Linking.openURL(url)
|
||||
},
|
||||
[enabled, inAppBrowserConsentControl, t, dialogContext],
|
||||
[ax, enabled, inAppBrowserConsentControl, t, dialogContext],
|
||||
)
|
||||
|
||||
return openLink
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {type Metrics, useAnalytics} from '#/analytics'
|
||||
|
||||
/**
|
||||
* Hook that returns a callback to track post:view events.
|
||||
@@ -12,8 +11,9 @@ import {type MetricEvents} from '#/logger/metrics'
|
||||
* @returns A callback that accepts a post and logs the view event
|
||||
*/
|
||||
export function usePostViewTracking(
|
||||
logContext: MetricEvents['post:view']['logContext'],
|
||||
logContext: Metrics['post:view']['logContext'],
|
||||
) {
|
||||
const ax = useAnalytics()
|
||||
const seenUrisRef = useRef(new Set<string>())
|
||||
|
||||
const trackPostView = useCallback(
|
||||
@@ -21,17 +21,13 @@ export function usePostViewTracking(
|
||||
if (seenUrisRef.current.has(post.uri)) return
|
||||
seenUrisRef.current.add(post.uri)
|
||||
|
||||
logger.metric(
|
||||
'post:view',
|
||||
{
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
},
|
||||
{statsig: false},
|
||||
)
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
logContext,
|
||||
})
|
||||
},
|
||||
[logContext],
|
||||
[ax, logContext],
|
||||
)
|
||||
|
||||
return trackPostView
|
||||
|
||||
@@ -2,8 +2,7 @@ import {useCallback, useEffect} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyNotificationRegisterPush} from '@atproto/api'
|
||||
import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {
|
||||
@@ -16,8 +15,8 @@ import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_DEV, IS_NATIVE} from '#/env'
|
||||
|
||||
/**
|
||||
* @private
|
||||
@@ -227,6 +226,7 @@ export function useNotificationsRegistration() {
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
@@ -251,7 +251,7 @@ export function useRequestNotificationsPermission() {
|
||||
|
||||
const res = await Notifications.requestPermissionsAsync()
|
||||
|
||||
notyLogger.metric(`notifications:request`, {
|
||||
ax.metric(`notifications:request`, {
|
||||
context: context,
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
|
||||
type Cb = () => void
|
||||
|
||||
export function callOnce() {
|
||||
let ran = false
|
||||
return function runCallbackOnce(cb: Cb) {
|
||||
if (ran) return
|
||||
ran = true
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
export function useCallOnce(cb: Cb): () => void
|
||||
export function useCallOnce(cb?: undefined): (cb: Cb) => void
|
||||
export function useCallOnce(cb?: Cb) {
|
||||
const ran = useRef(false)
|
||||
return useCallback(
|
||||
(icb: Cb) => {
|
||||
if (ran.current) return
|
||||
ran.current = true
|
||||
if (icb) icb()
|
||||
else if (cb) cb()
|
||||
},
|
||||
[cb],
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'is_bsky_team_member' // special, do not remove
|
||||
| 'disable_onboarding_find_contacts'
|
||||
| 'disable_settings_find_contacts'
|
||||
| 'disable_live_now_beta'
|
||||
@@ -1,327 +0,0 @@
|
||||
import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {IS_WEB} from '#/env'
|
||||
import * as env from '#/env'
|
||||
import {useSession} from '../../state/session'
|
||||
import {timeout} from '../async/timeout'
|
||||
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
|
||||
import {type Gate} from './gates'
|
||||
|
||||
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
|
||||
|
||||
export const initPromise = initialize()
|
||||
|
||||
type StatsigUser = {
|
||||
userID: string | undefined
|
||||
// TODO: Remove when enough users have custom.platform:
|
||||
platform: 'ios' | 'android' | 'web'
|
||||
custom: {
|
||||
// This is the place where we can add our own stuff.
|
||||
// Fields here have to be non-optional to be visible in the UI.
|
||||
platform: 'ios' | 'android' | 'web'
|
||||
appVersion: string
|
||||
bundleIdentifier: string
|
||||
bundleDate: number
|
||||
refSrc: string
|
||||
refUrl: string
|
||||
appLanguage: string
|
||||
contentLanguages: string[]
|
||||
}
|
||||
}
|
||||
|
||||
let refSrc = ''
|
||||
let refUrl = ''
|
||||
if (IS_WEB && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? ''
|
||||
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
|
||||
}
|
||||
|
||||
export type {MetricEvents as LogEvents}
|
||||
|
||||
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
return {
|
||||
environment: {
|
||||
tier: env.IS_DEV
|
||||
? 'development'
|
||||
: env.IS_TESTFLIGHT
|
||||
? 'staging'
|
||||
: 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
|
||||
initTimeoutMs: 1,
|
||||
// Get fresh flags for other accounts as well, if any.
|
||||
prefetchUsers,
|
||||
api: 'https://events.bsky.app/v2',
|
||||
}
|
||||
}
|
||||
|
||||
type FlatJSONRecord = Record<
|
||||
string,
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
// Technically not scalar but Statsig will stringify it which works for us:
|
||||
| string[]
|
||||
>
|
||||
|
||||
let getCurrentRouteName: () => string | null | undefined = () => null
|
||||
|
||||
export function attachRouteToLogEvents(
|
||||
getRouteName: () => string | null | undefined,
|
||||
) {
|
||||
getCurrentRouteName = getRouteName
|
||||
}
|
||||
|
||||
export function toClout(n: number | null | undefined): number | undefined {
|
||||
if (n == null) {
|
||||
return undefined
|
||||
} else {
|
||||
return Math.max(0, Math.round(Math.log(n)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `logger.metric()` instead
|
||||
*/
|
||||
export function logEvent<E extends keyof MetricEvents>(
|
||||
eventName: E & string,
|
||||
rawMetadata: MetricEvents[E] & FlatJSONRecord,
|
||||
options: {
|
||||
/**
|
||||
* Send to our data lake only, not to StatSig
|
||||
*/
|
||||
lake?: boolean
|
||||
} = {lake: false},
|
||||
) {
|
||||
try {
|
||||
const fullMetadata = toStringRecord(rawMetadata)
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
if (Statsig.initializeCalled()) {
|
||||
let ev: string = eventName
|
||||
if (options.lake) {
|
||||
ev = `lake:${ev}`
|
||||
}
|
||||
Statsig.logEvent(ev, null, fullMetadata)
|
||||
}
|
||||
/**
|
||||
* All datalake events should be sent using `logger.metric`, and we don't
|
||||
* want to double-emit logs to other transports.
|
||||
*/
|
||||
if (!options.lake) {
|
||||
logger.info(eventName, fullMetadata)
|
||||
}
|
||||
} catch (e) {
|
||||
// A log should never interrupt the calling code, whatever happens.
|
||||
logger.error('Failed to log an event', {message: e})
|
||||
}
|
||||
}
|
||||
|
||||
function toStringRecord<E extends keyof MetricEvents>(
|
||||
metadata: MetricEvents[E] & FlatJSONRecord,
|
||||
): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
for (let key in metadata) {
|
||||
if (metadata.hasOwnProperty(key)) {
|
||||
if (typeof metadata[key] === 'string') {
|
||||
record[key] = metadata[key]
|
||||
} else {
|
||||
record[key] = JSON.stringify(metadata[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// We roll our own cache in front of Statsig because it is a singleton
|
||||
// and it's been difficult to get it to behave in a predictable way.
|
||||
// Our own cache ensures consistent evaluation within a single session.
|
||||
const GateCache = React.createContext<Map<string, boolean> | null>(null)
|
||||
GateCache.displayName = 'StatsigGateCacheContext'
|
||||
|
||||
type GateOptions = {
|
||||
dangerouslyDisableExposureLogging?: boolean
|
||||
}
|
||||
|
||||
export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
|
||||
const cache = React.useContext(GateCache)
|
||||
if (!cache) {
|
||||
throw Error('useGate() cannot be called outside StatsigProvider.')
|
||||
}
|
||||
const gate = React.useCallback(
|
||||
(gateName: Gate, options: GateOptions = {}): boolean => {
|
||||
const cachedValue = cache.get(gateName)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue
|
||||
}
|
||||
let value = false
|
||||
if (Statsig.initializeCalled()) {
|
||||
if (options.dangerouslyDisableExposureLogging) {
|
||||
value = Statsig.checkGateWithExposureLoggingDisabled(gateName)
|
||||
} else {
|
||||
value = Statsig.checkGate(gateName)
|
||||
}
|
||||
}
|
||||
cache.set(gateName, value)
|
||||
return value
|
||||
},
|
||||
[cache],
|
||||
)
|
||||
return gate
|
||||
}
|
||||
|
||||
/**
|
||||
* Debugging tool to override a gate. USE ONLY IN E2E TESTS!
|
||||
*/
|
||||
export function useDangerousSetGate(): (
|
||||
gateName: Gate,
|
||||
value: boolean,
|
||||
) => void {
|
||||
const cache = React.useContext(GateCache)
|
||||
if (!cache) {
|
||||
throw Error(
|
||||
'useDangerousSetGate() cannot be called outside StatsigProvider.',
|
||||
)
|
||||
}
|
||||
const dangerousSetGate = React.useCallback(
|
||||
(gateName: Gate, value: boolean) => {
|
||||
cache.set(gateName, value)
|
||||
},
|
||||
[cache],
|
||||
)
|
||||
return dangerousSetGate
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined): StatsigUser {
|
||||
const languagePrefs = persisted.get('languagePrefs')
|
||||
return {
|
||||
userID: did,
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
custom: {
|
||||
refSrc,
|
||||
refUrl,
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
appVersion: env.RELEASE_VERSION,
|
||||
bundleIdentifier: env.BUNDLE_IDENTIFIER,
|
||||
bundleDate: env.BUNDLE_DATE,
|
||||
appLanguage: languagePrefs.appLanguage,
|
||||
contentLanguages: languagePrefs.contentLanguages,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
let lastActive = lastState === 'active' ? performance.now() : null
|
||||
AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
if (state === lastState) {
|
||||
return
|
||||
}
|
||||
lastState = state
|
||||
if (state === 'active') {
|
||||
lastActive = performance.now()
|
||||
logEvent('state:foreground', {})
|
||||
} else {
|
||||
let secondsActive = 0
|
||||
if (lastActive != null) {
|
||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||
lastActive = null
|
||||
logEvent('state:background', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export async function tryFetchGates(
|
||||
did: string | undefined,
|
||||
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
|
||||
) {
|
||||
try {
|
||||
let timeoutMs = 250 // Don't block the UI if we can't do this fast.
|
||||
if (strategy === 'prefer-fresh-gates') {
|
||||
// Use this for less common operations where the user would be OK with a delay.
|
||||
timeoutMs = 1500
|
||||
}
|
||||
if (Statsig.initializeCalled()) {
|
||||
await Promise.race([
|
||||
timeout(timeoutMs),
|
||||
Statsig.prefetchUsers([toStatsigUser(did)]),
|
||||
])
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't leak errors to the calling code, this is meant to be always safe.
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export function initialize() {
|
||||
return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const did = currentAccount?.did
|
||||
const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did])
|
||||
|
||||
const otherDidsConcatenated = accounts
|
||||
.map(account => account.did)
|
||||
.filter(accountDid => accountDid !== did)
|
||||
.join(' ') // We're only interested in DID changes.
|
||||
const otherStatsigUsers = React.useMemo(
|
||||
() => otherDidsConcatenated.split(' ').map(toStatsigUser),
|
||||
[otherDidsConcatenated],
|
||||
)
|
||||
const statsigOptions = React.useMemo(
|
||||
() => createStatsigOptions(otherStatsigUsers),
|
||||
[otherStatsigUsers],
|
||||
)
|
||||
|
||||
// Have our own cache in front of Statsig.
|
||||
// This ensures the results remain stable until the active DID changes.
|
||||
const [gateCache, setGateCache] = React.useState(() => new Map())
|
||||
const [prevDid, setPrevDid] = React.useState(did)
|
||||
if (did !== prevDid) {
|
||||
setPrevDid(did)
|
||||
setGateCache(new Map())
|
||||
}
|
||||
|
||||
// Periodically poll Statsig to get the current rule evaluations for all stored accounts.
|
||||
// These changes are prefetched and stored, but don't get applied until the active DID changes.
|
||||
// This ensures that when you switch an account, it already has fresh results by then.
|
||||
const handleIntervalTick = useNonReactiveCallback(() => {
|
||||
if (Statsig.initializeCalled()) {
|
||||
// Note: Only first five will be taken into account by Statsig.
|
||||
Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers])
|
||||
}
|
||||
})
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(handleIntervalTick, 60e3 /* 1 min */)
|
||||
return () => clearInterval(id)
|
||||
}, [handleIntervalTick])
|
||||
|
||||
return (
|
||||
<GateCache.Provider value={gateCache}>
|
||||
<StatsigProvider
|
||||
key={did}
|
||||
sdkKey={SDK_KEY}
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
// However, it ensures `isLoading` is always `false`.
|
||||
waitForInitialization={true}
|
||||
options={statsigOptions}>
|
||||
{children}
|
||||
</StatsigProvider>
|
||||
</GateCache.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user