Clear out statsig client
This commit is contained in:
+9
-293
@@ -1,312 +1,28 @@
|
|||||||
import React from 'react'
|
import type React from 'react'
|
||||||
import {Platform} from 'react-native'
|
|
||||||
import {AppState, type AppStateStatus} from 'react-native'
|
|
||||||
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
|
|
||||||
|
|
||||||
import {logger, type Metrics} from '#/logger'
|
import {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 Gate} from './gates'
|
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}
|
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
|
* @deprecated use `logger.metric()` instead
|
||||||
*/
|
*/
|
||||||
export function logEvent<E extends keyof Metrics>(
|
export function logEvent<E extends keyof Metrics>(
|
||||||
eventName: E & string,
|
_eventName: E & string,
|
||||||
rawMetadata: Metrics[E] & FlatJSONRecord,
|
_rawMetadata: Metrics[E] & any,
|
||||||
options: {
|
_options: {
|
||||||
/**
|
/**
|
||||||
* Send to our data lake only, not to StatSig
|
* Send to our data lake only, not to StatSig
|
||||||
*/
|
*/
|
||||||
lake?: boolean
|
lake?: boolean
|
||||||
} = {lake: false},
|
} = {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>(
|
export function useGate(): (_gateName: Gate, _options?: any) => boolean {
|
||||||
metadata: Metrics[E] & FlatJSONRecord,
|
return () => false
|
||||||
): 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 Provider({children}: {children: React.ReactNode}) {
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
const {currentAccount, accounts} = useSession()
|
return children
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user