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