diff --git a/src/App.native.tsx b/src/App.native.tsx index fb93534435..5a1da373d8 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -17,11 +17,15 @@ import * as Sentry from '@sentry/react-native' import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {QueryProvider} from '#/lib/react-query' -import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' +import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' -import {logger, Provider as LoggingProvider} from '#/logger' +import {logger} from '#/logger' +import { + initializer as growthbookInitializer, + Provider as GrowthBookProvider, +} from '#/logger/growthbook/context' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' @@ -111,10 +115,9 @@ function InnerApp() { useEffect(() => { async function onLaunch(account?: SessionAccount) { try { + await growthbookInitializer if (account) { await resumeSession(account) - } else { - await tryFetchGates(undefined, 'prefer-fresh-gates') } } catch (e) { logger.error(`session: resume failed`, {message: e}) @@ -144,57 +147,59 @@ function InnerApp() { - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -238,9 +243,7 @@ function App() { - - - + diff --git a/src/App.web.tsx b/src/App.web.tsx index 2eae93d3e2..6cd4ca679a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -12,8 +12,11 @@ import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' -import {logger, Provider as LoggingProvider} from '#/logger' -import {initializer as growthbookInitializer} from '#/logger/growthbook/context' +import {logger} from '#/logger' +import { + initializer as growthbookInitializer, + Provider as GrowthBookProvider, +} from '#/logger/growthbook/context' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' @@ -56,8 +59,10 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {ToastOutlet} from '#/components/Toast' -import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' -import {prefetchAgeAssuranceConfig} from '#/ageAssurance' +import { + prefetchAgeAssuranceConfig, + Provider as AgeAssuranceV2Provider, +} from '#/ageAssurance' import { prefetchLiveEvents, Provider as LiveEventsProvider, @@ -121,53 +126,55 @@ function InnerApp() { - - - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -207,9 +214,7 @@ function App() { - - - + diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx index 93d6126d99..524fd62bf9 100644 --- a/src/components/PostControls/DiscoverDebug.tsx +++ b/src/components/PostControls/DiscoverDebug.tsx @@ -4,6 +4,7 @@ import {t} from '@lingui/macro' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {useGate} from '#/lib/statsig/statsig' +import {logEvent} from '#/logger/growthbook/context' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import * as Toast from '#/components/Toast' @@ -32,6 +33,7 @@ export function DiscoverDebug({ style={[a.absolute, {zIndex: 1000, maxWidth: 65, bottom: -4}, a.left_0]} onPress={e => { e.stopPropagation() + logEvent('discover_debug:copy_feed_context', {feedContext}) Clipboard.setStringAsync(feedContext) Toast.show(t`Copied to clipboard`) }}> diff --git a/src/env/common.ts b/src/env/common.ts index 0cb8d67872..ec3a0e9f25 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -87,13 +87,14 @@ export const CHAT_PROXY_DID: Did = /** * Growthbook API host */ -export const GROWTHBOOK_API_HOST: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST +export const GROWTHBOOK_API_HOST: string | undefined = + process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST /** * Growthbook client key */ -export const GROWTHBOOK_CLIENT_KEY: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY - +export const GROWTHBOOK_CLIENT_KEY: string = + process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY || 'sdk-7gkUkGy9wguUjyFe' /** * Sentry DSN for telemetry diff --git a/src/logger/growthbook/context.tsx b/src/logger/growthbook/context.tsx index da45e886e3..24d837ebfe 100644 --- a/src/logger/growthbook/context.tsx +++ b/src/logger/growthbook/context.tsx @@ -1,23 +1,57 @@ -import {useMemo, useEffect} from 'react' +import {useCallback, useEffect} from 'react' import {Platform} from 'react-native' +import {growthbookTrackingPlugin} from '@growthbook/growthbook/plugins' +import {GrowthBook, GrowthBookProvider} from '@growthbook/growthbook-react' + +import {BSKY_SERVICE} from '#/lib/constants' import { - GrowthBook, - GrowthBookProvider, - useFeatureIsOn, -} from '@growthbook/growthbook-react' - -import * as env from '#/env' -import {useSession, SessionAccount} from '#/state/session' -import {useGeolocation} from '#/geolocation' -import * as persisted from '#/state/persisted' + getAndMigrateStableId, + getSessionId, + getStableId, + getStableIdOrThrow, +} from '#/logger/growthbook/identifiers' import * as referrer from '#/logger/growthbook/util/referrer' +import * as persisted from '#/state/persisted' +import {type SessionAccount, useSession} from '#/state/session' +import * as env from '#/env' +import {type Geolocation, useGeolocation} from '#/geolocation' -type DefaultAttributes = { +const TIMEOUT_INIT = 500 // TODO should base on p99 or something +const TIMEOUT_PREFER_LOW_LATENCY = 250 +const TIMEOUT_PREFER_FRESH_GATES = 1500 + +/** + * We vary the amount of time we wait for GrowthBook to fetch feature + * gates based on the strategy specified. + * + * TODO examples + */ +type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates' + +/** + * These are fields that are handled specially by GrowthBook + */ +type GrowthBookDefaultAttributes = { + /** Special GrowthBook field */ + device_id: string + /** Special GrowthBook field */ + session_id: string +} +/** + * These are user fields that are handled specially by GrowthBook + */ +type GrowthBookDefaultUserAttributes = { + /** Special GrowthBook field */ + user_id: string +} +type DefaultAttributes = GrowthBookDefaultAttributes & { + /** Custom field provided by our Geolocation context */ country: string } -type UserAttributes = { - id: string - pds: string | undefined +type UserAttributes = GrowthBookDefaultUserAttributes & { + // do not use `id`, GrowthBook will think it's the same as `device_id` + did: string + isBskyPds: boolean platform: string appVersion: string bundleIdentifier: string @@ -27,71 +61,158 @@ type UserAttributes = { appLanguage: string contentLanguages: string[] } +type Attributes = DefaultAttributes & UserAttributes + +/** + * We cache the geolocation outside of React to use when setting + * default attributes outside React, such as during `refresh()`. + */ +let unsafeGeolocation: Geolocation | null = null const gb = new GrowthBook({ apiHost: env.GROWTHBOOK_API_HOST, clientKey: env.GROWTHBOOK_CLIENT_KEY, + plugins: [growthbookTrackingPlugin()], trackingCallback: (experiment, result) => { - // TODO console.log('Experiment Viewed', { experimentId: experiment.key, variationId: result.key, }) + gb.logEvent('Experiment Viewed', { + experimentId: experiment.key, + variationId: result.key, + }) }, + attributes: { + device_id: getStableId() || 'unset', + session_id: getSessionId(), + } satisfies GrowthBookDefaultAttributes, }) -export const initializer = gb.init({ - timeout: 1e3, +/** + * Initializer promise that must be awaited before using the GrowthBook + * instance or rendering the `Provider`. 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 initializer = new Promise(async y => { + /* + * This _must_ happen first to ensure continuity of the device ID from + * StatSig to GrowthBook + */ + const id = await getAndMigrateStableId() + const attr: GrowthBookDefaultAttributes = { + device_id: id, + session_id: getSessionId(), + } + gb.setAttributes(attr) + await gb.init({timeout: TIMEOUT_INIT}) + y() }) -export function useGate(gate: string): boolean { - return useFeatureIsOn(gate) +/** + * Refresh feature gates from GrowthBook. Updates attributes based on the + * provided account, if any. + */ +export async function refresh({ + account, + strategy, +}: { + account?: SessionAccount + strategy: FeatureFetchStrategy +}) { + setAttributesForAccount(account) + await gb.refreshFeatures({ + timeout: + strategy === 'prefer-low-latency' + ? TIMEOUT_PREFER_LOW_LATENCY + : TIMEOUT_PREFER_FRESH_GATES, + }) } +/** + * Log a custom event to our backend, using GrowthBook's event logging system. + */ +export function logEvent(eventName: string, metadata?: Record) { + gb.logEvent(eventName, metadata) +} + +/** + * Hook to check if a feature gate is enabled + */ +export function useGate() { + return useCallback((gate: string): boolean => { + return gb.isOn(gate) + }, []) +} + +/** + * Main provider for GrowthBook and feature flag context. Should be rendered + * _after_ `initializer` is complete, and _after_ the Session provider. + */ export function Provider({children}: {children: React.ReactNode}) { const geo = useGeolocation() const {currentAccount} = useSession() - const defaultAttributes = useMemo( - () => ({ - country: geo.countryCode || 'unknown', - }), - [geo], - ) /** * Decorate existing attributes with any new default attributes */ useEffect(() => { + // cache outside react + unsafeGeolocation = geo const attr = { ...gb.getAttributes(), - ...defaultAttributes, + ...getDefaultAttributes(), } gb.setAttributes(attr) console.debug(`update attributes`, {attributes: attr}) - }, [defaultAttributes]) + }, [geo]) /** * Update user attributes on session change, and clear them on logout */ useEffect(() => { - if (currentAccount) { - const attr = getUserAttributes(currentAccount) - gb.setAttributes({ - ...defaultAttributes, - ...getUserAttributes(currentAccount), - }) - console.debug(`has session, set attributes`, {attributes: attr}) - } else { - gb.setAttributes(defaultAttributes) - console.debug(`no session, reset attributes`, { - attributes: defaultAttributes, - }) - } - }, [defaultAttributes, currentAccount]) + setAttributesForAccount(currentAccount) + }, [currentAccount]) return {children} } +/** + * Get the default attributes that should always be set + * on the GrowthBook instance + */ +function getDefaultAttributes() { + return { + device_id: getStableIdOrThrow(), + session_id: getSessionId(), + country: unsafeGeolocation?.countryCode || 'unknown', + } +} + +/** + * Set attributes on the global GrowthBook instance. If an account is provided, + * set user attributes as well. Otherwise, clear user attributes. + */ +function setAttributesForAccount(account?: SessionAccount) { + if (account) { + const attr: Attributes = { + ...getDefaultAttributes(), + ...(getUserAttributes(account) || {}), + } + gb.setAttributes(attr) + console.debug(`setAttributesForAccount: has account`, {attributes: attr}) + } else { + const attr = getDefaultAttributes() + gb.setAttributes(attr) + console.debug(`setAttributesForAccount: no account`, {attributes: attr}) + } +} + +/** + * Converts a SessionAccount into user attributes for GrowthBook + */ export function getUserAttributes(account: SessionAccount): UserAttributes export function getUserAttributes(account: undefined): null export function getUserAttributes( @@ -100,8 +221,9 @@ export function getUserAttributes( if (!account) return null const languagePrefs = persisted.get('languagePrefs') return { - id: account.did, - pds: account.pdsUrl, + user_id: account.did, + did: account.did, + isBskyPds: account.service.startsWith(BSKY_SERVICE), platform: Platform.OS, appVersion: env.RELEASE_VERSION, bundleIdentifier: env.BUNDLE_IDENTIFIER, diff --git a/src/logger/growthbook/identifiers/common.ts b/src/logger/growthbook/identifiers/common.ts new file mode 100644 index 0000000000..1941ce2686 --- /dev/null +++ b/src/logger/growthbook/identifiers/common.ts @@ -0,0 +1,24 @@ +import uuid from 'react-native-uuid' +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {device} from '#/storage' + +const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID' + +export async function getAndMigrateStableId() { + const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4() + device.set(['stableId'], id) + return id +} + +export function getStableId() { + return device.get(['stableId']) +} + +export function getStableIdOrThrow() { + const id = device.get(['stableId']) + if (!id) { + throw new Error(`stableId is not set, call getAndMigrateStableId first`) + } + return id +} diff --git a/src/logger/growthbook/identifiers/index.ts b/src/logger/growthbook/identifiers/index.ts new file mode 100644 index 0000000000..b9f1708519 --- /dev/null +++ b/src/logger/growthbook/identifiers/index.ts @@ -0,0 +1,10 @@ +import uuid from 'react-native-uuid' + +export * from '#/logger/growthbook/identifiers/common' + +// TODO probably want to clear this +const sessionId = uuid.v4() +/** + * Stable session ID, persisted for the duration of the user's session + */ +export const getSessionId = () => sessionId diff --git a/src/logger/growthbook/identifiers/index.web.ts b/src/logger/growthbook/identifiers/index.web.ts new file mode 100644 index 0000000000..1ea2da5157 --- /dev/null +++ b/src/logger/growthbook/identifiers/index.web.ts @@ -0,0 +1,15 @@ +import uuid from 'react-native-uuid' + +export * from '#/logger/growthbook/identifiers/common' + +/** + * Stable session ID, persisted for the duration of the user's session + */ +export const getSessionId = () => { + let id = sessionStorage.getItem('BSKY_SESSION_ID') + if (!id) { + id = uuid.v4() + sessionStorage.setItem('BSKY_SESSION_ID', id) + } + return id +} diff --git a/src/logger/index.tsx b/src/logger/index.tsx index cabb76653e..3da8908c79 100644 --- a/src/logger/index.tsx +++ b/src/logger/index.tsx @@ -1,6 +1,5 @@ import {nanoid} from 'nanoid/non-secure' -import {Provider as GrowthbookProvider} from '#/logger/growthbook/context' import {logEvent} from '#/lib/statsig/statsig' import {add} from '#/logger/logDump' import {type MetricEvents} from '#/logger/metrics' @@ -172,10 +171,3 @@ export class Logger { * `logger.error(error[, metadata])` */ export const logger = Logger.create(Logger.Context.Default) - -/** - * Logger context provider - */ -export function Provider({children}: {children: React.ReactNode}) { - return {children} -} diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 5c8ce3b97f..7f804feedc 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -22,9 +22,9 @@ import { PUBLIC_BSKY_SERVICE, TIMELINE_SAVED_FEED, } from '#/lib/constants' -import {tryFetchGates} from '#/lib/statsig/statsig' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' +import {refresh as refreshGates} from '#/logger/growthbook/context' import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import { @@ -63,7 +63,10 @@ export async function createAgentAndResume( if (storedAccount.pdsUrl) { agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) } - const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') + const gates = refreshGates({ + account: storedAccount, + strategy: 'prefer-low-latency', + }) const moderation = configureModerationForAccount(agent, storedAccount) const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) if (isSessionExpired(storedAccount)) { @@ -123,7 +126,7 @@ export async function createAgentAndLogin( }) const account = agentToSessionAccountOrThrow(agent) - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') + const gates = refreshGates({account, strategy: 'prefer-fresh-gates'}) const moderation = configureModerationForAccount(agent, account) const aa = prefetchAgeAssuranceData({agent}) @@ -171,7 +174,7 @@ export async function createAgentAndCreateAccount( verificationCode, }) const account = agentToSessionAccountOrThrow(agent) - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') + const gates = refreshGates({account, strategy: 'prefer-fresh-gates'}) const moderation = configureModerationForAccount(agent, account) const createdAt = new Date().toISOString() @@ -322,7 +325,7 @@ export function agentToSessionAccount( return undefined } return { - service: agent.service.toString(), + service: agent.serviceUrl.toString(), did: agent.session.did, handle: agent.session.handle, email: agent.session.email, @@ -332,7 +335,7 @@ export function agentToSessionAccount( accessJwt: agent.session.accessJwt, signupQueued: isSignupQueued(agent.session.accessJwt), active: agent.session.active, - status: agent.session.status as SessionAccount['status'], + status: agent.session.status, pdsUrl: agent.pdsUrl?.toString(), isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), } diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 501dafee4b..21abc60ed8 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -5,6 +5,12 @@ import {type Geolocation} from '#/geolocation/types' * Device data that's specific to the device and does not vary based account */ export type Device = { + /** + * Formerly managed by StatSig, this is the migrated stable ID for the + * device, used with our logging and metrics tracking. + */ + stableId: string | undefined + fontScale: '-2' | '-1' | '0' | '1' | '2' fontFamily: 'system' | 'theme' lastNuxDialog: string | undefined