Integrate feature gates into analytics context

This commit is contained in:
Eric Bailey
2026-01-21 10:11:32 -06:00
parent 2738a46737
commit d08f5cf9d9
8 changed files with 253 additions and 116 deletions
+60 -54
View File
@@ -22,7 +22,6 @@ import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {initializer as growthbookInitializer} from '#/logger/growthbook'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -70,7 +69,12 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {AnalyticsContext, setupDeviceId} from '#/analytics'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {
prefetchLiveEvents,
@@ -116,7 +120,7 @@ function InnerApp() {
if (account) {
await resumeSession(account)
} else {
await growthbookInitializer
await features.init
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -146,57 +150,59 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</VideoVolumeProvider>
</Splash>
+56 -50
View File
@@ -13,7 +13,6 @@ import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {initializer as growthbookInitializer} from '#/logger/growthbook'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -60,7 +59,12 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {AnalyticsContext, setupDeviceId} from '#/analytics'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -92,7 +96,7 @@ function InnerApp() {
if (account) {
await resumeSession(account)
} else {
await growthbookInitializer
await features.init
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
@@ -125,53 +129,55 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
+62
View File
@@ -0,0 +1,62 @@
import {GrowthBook} from '@growthbook/growthbook-react'
import {type Metadata} from '#/analytics/types'
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<void>(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 || {}),
})
}
+7
View File
@@ -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',
}
+61 -6
View File
@@ -1,6 +1,13 @@
import {createContext, useContext, useMemo} from 'react'
import {createContext, useContext, useEffect, useMemo} from 'react'
import {Platform} from 'react-native'
import {
Features,
features as feats,
init,
refresh,
setAttributes,
} from '#/analytics/features'
import {
getAndMigrateDeviceId,
getDeviceIdOrThrow,
@@ -15,17 +22,24 @@ import {useGeolocation} from '#/geolocation'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
export const features = {init, refresh}
type ContextType = {
type AnalyticsContextType = {
metadata: Metadata
metric: <E extends keyof Metrics>(
event: E,
payload: Metrics[E],
metadata?: MergeableMetadata,
) => void
metadata: Metadata
feature: (feature: Features) => boolean
Features: typeof Features
}
type AnalyticsBaseContextType = Omit<
AnalyticsContextType,
'feature' | 'Features'
>
const Context = createContext<ContextType>({
const Context = createContext<AnalyticsBaseContextType>({
metric: (event, payload, metadata) => {
metrics.track(event, payload, metadata)
},
@@ -74,7 +88,7 @@ export function AnalyticsContext({
}
combinedMetadata.base.sessionId = sessionId
combinedMetadata.geolocation = geolocation
const context: ContextType = {
const context: AnalyticsBaseContextType = {
metadata: combinedMetadata,
metric: (event, payload, extraMetadata) => {
parentContext.metric(event, payload, {
@@ -88,6 +102,47 @@ export function AnalyticsContext({
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
export function useAnalytics() {
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<AnalyticsContextType>(() => {
return {
...parentContext,
feature: feats.isOn.bind(feats),
Features,
}
}, [parentContext])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
export function useAnalyticsBase() {
return useContext(Context)
}
export function useAnalytics() {
const ctx = useContext(Context)
if (!('feature' in ctx) || !('Features' in ctx)) {
throw new Error(
'useAnalytics must be used within an AnalyticsFeaturesContext',
)
}
return ctx as AnalyticsContextType
}
+1
View File
@@ -5,6 +5,7 @@ import {type SessionAccount} from '#/state/session'
import {type MergeableMetadata, type SessionMetadata} from '#/analytics/types'
export function meta(metadata: MergeableMetadata) {
// I don't care
// eslint-disable-next-line react-hooks/rules-of-hooks
const m = useMemo(() => metadata, [metadata])
// @ts-ignore
+4 -4
View File
@@ -24,7 +24,6 @@ import {
} from '#/lib/constants'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {refresh as refreshGates} from '#/logger/growthbook'
import {setUserMetadata} from '#/logger/metadata'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
@@ -33,6 +32,7 @@ import {
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {features} from '#/analytics'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
@@ -65,7 +65,7 @@ export async function createAgentAndResume(
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
}
setUserMetadata(storedAccount)
const gates = refreshGates({
const gates = features.refresh({
strategy: 'prefer-low-latency',
})
const moderation = configureModerationForAccount(agent, storedAccount)
@@ -128,7 +128,7 @@ export async function createAgentAndLogin(
const account = agentToSessionAccountOrThrow(agent)
setUserMetadata(account)
const gates = refreshGates({strategy: 'prefer-fresh-gates'})
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const aa = prefetchAgeAssuranceData({agent})
@@ -177,7 +177,7 @@ export async function createAgentAndCreateAccount(
})
const account = agentToSessionAccountOrThrow(agent)
setUserMetadata(account)
const gates = refreshGates({strategy: 'prefer-fresh-gates'})
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const createdAt = new Date().toISOString()
+2 -2
View File
@@ -4,7 +4,7 @@ import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalytics, utils} from '#/analytics'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {emitSessionDropped} from '../events'
import {
@@ -92,7 +92,7 @@ class SessionStore {
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
const ax = useAnalyticsBase()
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)