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)