Files
bsky-social-app/src/lib/statsig/statsig.tsx
T
Eric Bailey 96f4f6359a Logger metrics (#7867)
* Adjust datalake abstraction

(cherry picked from commit 8ba6a8d45b1bd5698afbd06d9e858a91789f0ea6)

* Just be really really specific

(cherry picked from commit 920198959659329a7f7f7282a1293aaad198d8e3)

* Add metric method to logger, replace datalake calls with new method

(cherry picked from commit 7a026bbeae75514b64f928d7ff59707c518fd5e5)

* Clarify types

(cherry picked from commit 422b150deb158a70ef37e8a456d91bf26cd0b1bc)
2025-02-28 17:13:49 -06:00

326 lines
9.6 KiB
TypeScript

import React from 'react'
import {Platform} from 'react-native'
import {AppState, AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
export const initPromise = initialize()
type StatsigUser = {
userID: string | undefined
// TODO: Remove when enough users have custom.platform:
platform: 'ios' | 'android' | 'web'
custom: {
// This is the place where we can add our own stuff.
// Fields here have to be non-optional to be visible in the UI.
platform: 'ios' | 'android' | 'web'
bundleIdentifier: string
bundleDate: number
refSrc: string
refUrl: string
appLanguage: string
contentLanguages: string[]
}
}
let refSrc = ''
let refUrl = ''
if (isWeb && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export type {MetricEvents as LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
return {
environment: {
tier:
process.env.NODE_ENV === 'development'
? 'development'
: IS_TESTFLIGHT
? 'staging'
: 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
initTimeoutMs: 1,
// Get fresh flags for other accounts as well, if any.
prefetchUsers,
api: 'https://events.bsky.app/v2',
}
}
type FlatJSONRecord = Record<
string,
| string
| number
| boolean
| null
| undefined
// Technically not scalar but Statsig will stringify it which works for us:
| string[]
>
let getCurrentRouteName: () => string | null | undefined = () => null
export function attachRouteToLogEvents(
getRouteName: () => string | null | undefined,
) {
getCurrentRouteName = getRouteName
}
export function toClout(n: number | null | undefined): number | undefined {
if (n == null) {
return undefined
} else {
return Math.max(0, Math.round(Math.log(n)))
}
}
/**
* @deprecated use `logger.metric()` instead
*/
export function logEvent<E extends keyof MetricEvents>(
eventName: E & string,
rawMetadata: MetricEvents[E] & FlatJSONRecord,
options: {
/**
* Send to our data lake only, not to StatSig
*/
lake?: boolean
} = {lake: false},
) {
try {
const fullMetadata = toStringRecord(rawMetadata)
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
if (Statsig.initializeCalled()) {
let ev: string = eventName
if (options.lake) {
ev = `lake:${ev}`
}
Statsig.logEvent(ev, null, fullMetadata)
}
/**
* All datalake events should be sent using `logger.metric`, and we don't
* want to double-emit logs to other transports.
*/
if (!options.lake) {
logger.info(eventName, fullMetadata)
}
} catch (e) {
// A log should never interrupt the calling code, whatever happens.
logger.error('Failed to log an event', {message: e})
}
}
function toStringRecord<E extends keyof MetricEvents>(
metadata: MetricEvents[E] & FlatJSONRecord,
): Record<string, string> {
const record: Record<string, string> = {}
for (let key in metadata) {
if (metadata.hasOwnProperty(key)) {
if (typeof metadata[key] === 'string') {
record[key] = metadata[key]
} else {
record[key] = JSON.stringify(metadata[key])
}
}
}
return record
}
// We roll our own cache in front of Statsig because it is a singleton
// and it's been difficult to get it to behave in a predictable way.
// Our own cache ensures consistent evaluation within a single session.
const GateCache = React.createContext<Map<string, boolean> | null>(null)
type GateOptions = {
dangerouslyDisableExposureLogging?: boolean
}
export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
const cache = React.useContext(GateCache)
if (!cache) {
throw Error('useGate() cannot be called outside StatsigProvider.')
}
const gate = React.useCallback(
(gateName: Gate, options: GateOptions = {}): boolean => {
const cachedValue = cache.get(gateName)
if (cachedValue !== undefined) {
return cachedValue
}
let value = false
if (Statsig.initializeCalled()) {
if (options.dangerouslyDisableExposureLogging) {
value = Statsig.checkGateWithExposureLoggingDisabled(gateName)
} else {
value = Statsig.checkGate(gateName)
}
}
cache.set(gateName, value)
return value
},
[cache],
)
return gate
}
/**
* Debugging tool to override a gate. USE ONLY IN E2E TESTS!
*/
export function useDangerousSetGate(): (
gateName: Gate,
value: boolean,
) => void {
const cache = React.useContext(GateCache)
if (!cache) {
throw Error(
'useDangerousSetGate() cannot be called outside StatsigProvider.',
)
}
const dangerousSetGate = React.useCallback(
(gateName: Gate, value: boolean) => {
cache.set(gateName, value)
},
[cache],
)
return dangerousSetGate
}
function toStatsigUser(did: string | undefined): StatsigUser {
const languagePrefs = persisted.get('languagePrefs')
return {
userID: did,
platform: Platform.OS as 'ios' | 'android' | 'web',
custom: {
refSrc,
refUrl,
platform: Platform.OS as 'ios' | 'android' | 'web',
bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE,
appLanguage: languagePrefs.appLanguage,
contentLanguages: languagePrefs.contentLanguages,
},
}
}
let lastState: AppStateStatus = AppState.currentState
let lastActive = lastState === 'active' ? performance.now() : null
AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === lastState) {
return
}
lastState = state
if (state === 'active') {
lastActive = performance.now()
logEvent('state:foreground', {})
} else {
let secondsActive = 0
if (lastActive != null) {
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
lastActive = null
logEvent('state:background', {
secondsActive,
})
}
}
})
export async function tryFetchGates(
did: string | undefined,
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
) {
try {
let timeoutMs = 250 // Don't block the UI if we can't do this fast.
if (strategy === 'prefer-fresh-gates') {
// Use this for less common operations where the user would be OK with a delay.
timeoutMs = 1500
}
if (Statsig.initializeCalled()) {
await Promise.race([
timeout(timeoutMs),
Statsig.prefetchUsers([toStatsigUser(did)]),
])
}
} catch (e) {
// Don't leak errors to the calling code, this is meant to be always safe.
console.error(e)
}
}
export function initialize() {
return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
}
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount, accounts} = useSession()
const did = currentAccount?.did
const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did])
const otherDidsConcatenated = accounts
.map(account => account.did)
.filter(accountDid => accountDid !== did)
.join(' ') // We're only interested in DID changes.
const otherStatsigUsers = React.useMemo(
() => otherDidsConcatenated.split(' ').map(toStatsigUser),
[otherDidsConcatenated],
)
const statsigOptions = React.useMemo(
() => createStatsigOptions(otherStatsigUsers),
[otherStatsigUsers],
)
// Have our own cache in front of Statsig.
// This ensures the results remain stable until the active DID changes.
const [gateCache, setGateCache] = React.useState(() => new Map())
const [prevDid, setPrevDid] = React.useState(did)
if (did !== prevDid) {
setPrevDid(did)
setGateCache(new Map())
}
// Periodically poll Statsig to get the current rule evaluations for all stored accounts.
// These changes are prefetched and stored, but don't get applied until the active DID changes.
// This ensures that when you switch an account, it already has fresh results by then.
const handleIntervalTick = useNonReactiveCallback(() => {
if (Statsig.initializeCalled()) {
// Note: Only first five will be taken into account by Statsig.
Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers])
}
})
React.useEffect(() => {
const id = setInterval(handleIntervalTick, 60e3 /* 1 min */)
return () => clearInterval(id)
}, [handleIntervalTick])
return (
<GateCache.Provider value={gateCache}>
<StatsigProvider
key={did}
sdkKey={SDK_KEY}
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
// However, it ensures `isLoading` is always `false`.
waitForInitialization={true}
options={statsigOptions}>
{children}
</StatsigProvider>
</GateCache.Provider>
)
}