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
+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