Clear out statsig client
This commit is contained in:
+9
-293
@@ -1,312 +1,28 @@
|
||||
import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
|
||||
import type React from 'react'
|
||||
|
||||
import {logger, type Metrics} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {IS_WEB} from '#/env'
|
||||
import * as env from '#/env'
|
||||
import {useSession} from '../../state/session'
|
||||
import {timeout} from '../async/timeout'
|
||||
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
|
||||
import {type Metrics} from '#/logger'
|
||||
import {type 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'
|
||||
appVersion: string
|
||||
bundleIdentifier: string
|
||||
bundleDate: number
|
||||
refSrc: string
|
||||
refUrl: string
|
||||
appLanguage: string
|
||||
contentLanguages: string[]
|
||||
}
|
||||
}
|
||||
|
||||
let refSrc = ''
|
||||
let refUrl = ''
|
||||
if (IS_WEB && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? ''
|
||||
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
|
||||
}
|
||||
|
||||
export type {Metrics as LogEvents}
|
||||
|
||||
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
return {
|
||||
environment: {
|
||||
tier: env.IS_DEV
|
||||
? 'development'
|
||||
: env.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
|
||||
|
||||
/**
|
||||
* @deprecated use `logger.metric()` instead
|
||||
*/
|
||||
export function logEvent<E extends keyof Metrics>(
|
||||
eventName: E & string,
|
||||
rawMetadata: Metrics[E] & FlatJSONRecord,
|
||||
options: {
|
||||
_eventName: E & string,
|
||||
_rawMetadata: Metrics[E] & any,
|
||||
_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 Metrics>(
|
||||
metadata: Metrics[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)
|
||||
GateCache.displayName = 'StatsigGateCacheContext'
|
||||
|
||||
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',
|
||||
appVersion: env.RELEASE_VERSION,
|
||||
bundleIdentifier: env.BUNDLE_IDENTIFIER,
|
||||
bundleDate: env.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 useGate(): (_gateName: Gate, _options?: any) => boolean {
|
||||
return () => false
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return children
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user