diff --git a/src/App.native.tsx b/src/App.native.tsx
index 4cc30cca98..fe57ba86d3 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -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() {
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 48a79b1aae..0a632ccdbf 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -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() {
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts
new file mode 100644
index 0000000000..2edd75fed5
--- /dev/null
+++ b/src/analytics/features/index.ts
@@ -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(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/index.tsx b/src/analytics/index.tsx
index 706d37bda3..e3b86e2c03 100644
--- a/src/analytics/index.tsx
+++ b/src/analytics/index.tsx
@@ -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: (
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({
+const Context = createContext({
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 {children}
}
-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(() => {
+ return {
+ ...parentContext,
+ feature: feats.isOn.bind(feats),
+ Features,
+ }
+ }, [parentContext])
+
+ return {children}
+}
+
+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
+}
diff --git a/src/analytics/utils.ts b/src/analytics/utils.ts
index 5a0099bfc1..d21ccf8080 100644
--- a/src/analytics/utils.ts
+++ b/src/analytics/utils.ts
@@ -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
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 0eb53349fd..515df62a9d 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -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()
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 609eb6d895..addbd04525 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -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)