Add metrics client

This commit is contained in:
Eric Bailey
2026-01-20 15:36:16 -06:00
parent 5c2a799280
commit 5b8f8bb234
15 changed files with 217 additions and 84 deletions
+3
View File
@@ -28,6 +28,9 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
#
#
# Bluesky's metrics API
EXPO_PUBLIC_METRICS_API_HOST=
# Growthbook config
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
+6 -8
View File
@@ -8,8 +8,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {logger, type Metrics} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -450,12 +449,11 @@ export function ProfileGrid({
const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
// Callback to fire seen events
const fireSeen = useCallback(() => {
@@ -4,7 +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'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Toast from '#/components/Toast'
@@ -33,7 +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})
logger.metric('debug', {feedContext})
Clipboard.setStringAsync(feedContext)
Toast.show(t`Copied to clipboard`)
}}>
+8 -2
View File
@@ -84,11 +84,17 @@ export const BLUESKY_PROXY_DID: Did =
export const CHAT_PROXY_DID: Did =
process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat'
/**
* Metrics API host
*/
export const METRICS_API_HOST: string =
process.env.EXPO_PUBLIC_METRICS_API_HOST || 'https://events.bsky.app'
/**
* Growthbook API host
*/
export const GROWTHBOOK_API_HOST: string | undefined =
process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST
export const GROWTHBOOK_API_HOST: string =
process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb`
/**
* Growthbook client key
+25
View File
@@ -0,0 +1,25 @@
import {useEffect, useState} from 'react'
import {AppState, type AppStateStatus} from 'react-native'
export const getCurrentState = () => AppState.currentState
export function onAppStateChange(cb: (state: AppStateStatus) => void) {
let prev = AppState.currentState
return AppState.addEventListener('change', next => {
if (next === prev) return
cb(next)
})
}
export function useAppState() {
const [state, setState] = useState(AppState.currentState)
useEffect(() => {
const sub = onAppStateChange(next => {
setState(next)
})
return () => sub.remove()
}, [])
return state
}
+5 -14
View File
@@ -1,15 +1,6 @@
import {useEffect, useState} from 'react'
import {AppState} from 'react-native'
import {useAppState as useAppStateBase} from '#/lib/appState'
export function useAppState() {
const [state, setState] = useState(AppState.currentState)
useEffect(() => {
const sub = AppState.addEventListener('change', nextAppState => {
setState(nextAppState)
})
return () => sub.remove()
}, [])
return state
}
/**
* @deprecated use `useAppState` from `#/lib/appState` instead
*/
export const useAppState = useAppStateBase
+2 -3
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef} from 'react'
import {type AppBskyFeedDefs} from '@atproto/api'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {logger, type Metrics} from '#/logger'
/**
* Hook that returns a callback to track post:view events.
@@ -12,7 +11,7 @@ import {type MetricEvents} from '#/logger/metrics'
* @returns A callback that accepts a post and logs the view event
*/
export function usePostViewTracking(
logContext: MetricEvents['post:view']['logContext'],
logContext: Metrics['post:view']['logContext'],
) {
const seenUrisRef = useRef(new Set<string>())
+6 -7
View File
@@ -3,8 +3,7 @@ import {Platform} from 'react-native'
import {AppState, type AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {logger, type Metrics} from '#/logger'
import * as persisted from '#/state/persisted'
import {IS_WEB} from '#/env'
import * as env from '#/env'
@@ -43,7 +42,7 @@ if (IS_WEB && typeof window !== 'undefined') {
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export type {MetricEvents as LogEvents}
export type {Metrics as LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
return {
@@ -94,9 +93,9 @@ export function toClout(n: number | null | undefined): number | undefined {
/**
* @deprecated use `logger.metric()` instead
*/
export function logEvent<E extends keyof MetricEvents>(
export function logEvent<E extends keyof Metrics>(
eventName: E & string,
rawMetadata: MetricEvents[E] & FlatJSONRecord,
rawMetadata: Metrics[E] & FlatJSONRecord,
options: {
/**
* Send to our data lake only, not to StatSig
@@ -127,8 +126,8 @@ export function logEvent<E extends keyof MetricEvents>(
}
}
function toStringRecord<E extends keyof MetricEvents>(
metadata: MetricEvents[E] & FlatJSONRecord,
function toStringRecord<E extends keyof Metrics>(
metadata: Metrics[E] & FlatJSONRecord,
): Record<string, string> {
const record: Record<string, string> = {}
for (let key in metadata) {
+21 -17
View File
@@ -1,6 +1,5 @@
import {useCallback} from 'react'
import {Platform} from 'react-native'
import {growthbookTrackingPlugin} from '@growthbook/growthbook/plugins'
import {GrowthBook} from '@growthbook/growthbook-react'
import {BSKY_SERVICE} from '#/lib/constants'
@@ -15,6 +14,13 @@ import {type SessionAccount} from '#/state/session'
import * as env from '#/env'
import {device} from '#/storage'
const debugEnabled = env.IS_DEV && true
const debug = (message: string, attributes?: Record<string, any>) => {
if (debugEnabled) {
console.debug(`(growthbook) ${message}`, attributes || {})
}
}
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500
@@ -60,21 +66,17 @@ type UserAttributes = GrowthBookDefaultUserAttributes & {
appLanguage: string
contentLanguages: string[]
}
type Attributes = DefaultAttributes & UserAttributes
export type Attributes = DefaultAttributes & Partial<UserAttributes>
const gb = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
plugins: [growthbookTrackingPlugin()],
trackingCallback: (experiment, result) => {
console.debug('Experiment Viewed', {
experimentId: experiment.key,
variationId: result.key,
})
gb.logEvent('Experiment Viewed', {
debug(`Experiment Viewed`, {
experimentId: experiment.key,
variationId: result.key,
})
// TODO
},
attributes: getDefaultAttributes(),
})
@@ -101,6 +103,14 @@ export const initializer = new Promise<void>(async y => {
y()
})
export function getGrowthBook() {
return gb
}
export function getGrowthBookAttributes(): Attributes {
return gb.getAttributes() as Attributes
}
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
@@ -112,6 +122,7 @@ export async function refresh({
account?: SessionAccount
strategy: FeatureFetchStrategy
}) {
debug(`refresh`, {account: !!account, strategy})
setAttributesForAccount(account)
await gb.refreshFeatures({
timeout:
@@ -121,13 +132,6 @@ export async function refresh({
})
}
/**
* Log a custom event to our backend, using GrowthBook's event logging system.
*/
export function logEvent(eventName: string, metadata?: Record<string, any>) {
gb.logEvent(eventName, metadata)
}
/**
* Hook to check if a feature gate is enabled
*/
@@ -160,11 +164,11 @@ function setAttributesForAccount(account?: SessionAccount) {
...(getUserAttributes(account) || {}),
}
gb.setAttributes(attr)
console.debug(`setAttributesForAccount: has account`, {attributes: attr})
debug(`setAttributesForAccount: has account`, {attributes: attr})
} else {
const attr = getDefaultAttributes()
gb.setAttributes(attr)
console.debug(`setAttributesForAccount: no account`, {attributes: attr})
debug(`setAttributesForAccount: no account`, {attributes: attr})
}
}
+6 -10
View File
@@ -1,8 +1,7 @@
import {nanoid} from 'nanoid/non-secure'
import {logEvent} from '#/logger/growthbook'
import {add} from '#/logger/logDump'
import {type MetricEvents} from '#/logger/metrics'
import {type Metrics, metrics} from '#/logger/metrics'
import {consoleTransport} from '#/logger/transports/console'
import {sentryTransport} from '#/logger/transports/sentry'
import {
@@ -14,7 +13,7 @@ import {
import {enabledLogLevels} from '#/logger/util'
import {ENV} from '#/env'
export {type MetricEvents as Metrics} from '#/logger/metrics'
export {type Metrics} from '#/logger/metrics'
const TRANSPORTS: Transport[] = (function configureTransports() {
switch (ENV) {
@@ -95,20 +94,17 @@ export class Logger {
this.transport({level: LogLevel.Error, message: error, metadata})
}
metric<E extends keyof MetricEvents>(
metric<E extends keyof Metrics>(
event: E & string,
metadata: MetricEvents[E],
options: {
metadata: Metrics[E],
_: {
/**
* Optionally also send to StatSig
*/
statsig?: boolean
} = {statsig: true},
) {
logEvent(event, metadata, {
lake: !options.statsig,
})
metrics.track(event, metadata)
for (const transport of this.transports) {
transport(LogLevel.Info, LogContext.Metric, event, metadata, Date.now())
}
@@ -2,7 +2,7 @@ import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = {
export type Metrics = {
// App events
init: {
initMs: number
@@ -374,7 +374,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
@@ -468,7 +467,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
+124
View File
@@ -0,0 +1,124 @@
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {isNetworkError} from '#/lib/strings/errors'
import {
type Attributes,
getGrowthBook,
getGrowthBookAttributes,
} from '#/logger/growthbook'
import {type Metrics} from '#/logger/metrics/events'
import {Sentry} from '#/logger/sentry/lib'
import * as env from '#/env'
export {type Metrics} from '#/logger/metrics/events'
type Event<M extends Metrics> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Attributes
}
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/track'
export const metrics = new (class Tracker {
private started: boolean = false
private queue: Event<Metrics>[] = []
private failedQueue: Event<Metrics>[] = []
private flushInterval: NodeJS.Timeout | null = null
start() {
if (this.started) return
if (!getGrowthBook().ready) return
this.started = true
this.flushInterval = setInterval(() => {
this.flush()
}, 10_000)
onAppStateChange(state => {
if (state === 'active') {
this.retryFailedLogs()
} else {
this.flush()
}
})
}
track<E extends keyof Metrics>(event: E, payload: Metrics[E]) {
this.start()
this.queue.push({
time: Date.now(),
event,
payload,
metadata: getGrowthBookAttributes(),
})
if (this.queue.length > 100) {
this.flush()
}
}
flush() {
if (!this.queue.length) return
const events = this.queue.splice(0, this.queue.length)
this.queue = []
this.sendBatch(events)
}
private async sendBatch(events: Event<Metrics>[], isRetry: boolean = false) {
try {
const body = JSON.stringify(events)
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
const success = navigator.sendBeacon(
TRACKING_ENDPOINT,
new Blob([body], {type: 'application/json'}),
)
console.log({success})
} else {
const res = await fetch(TRACKING_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(events),
keepalive: true,
})
if (!res.ok) {
const error = await res.text().catch(() => 'Unknown error')
// construct a "network error" for `isNetworkError` to work
throw new Error(`${res.status} Failed to fetch — ${error}`)
}
}
} catch (e: any) {
if (isNetworkError(e)) {
if (isRetry) return // retry once
this.failedQueue.push(...events)
return
}
Sentry.captureException(`Failed to send metrics`, {
extra: {
safeMessage: e.toString(),
},
})
}
}
private retryFailedLogs() {
if (!this.failedQueue.length) return
const events = this.failedQueue.splice(0, this.failedQueue.length)
this.failedQueue = []
this.sendBatch(events, true)
}
})()
let lastActive = getCurrentState() === 'active' ? performance.now() : null
onAppStateChange(state => {
if (state === 'active') {
lastActive = performance.now()
metrics.track('state:foreground', {})
} else if (lastActive !== null) {
metrics.track('state:background', {
secondsActive: Math.round((performance.now() - lastActive) / 1e3),
})
}
})
-1
View File
@@ -15,7 +15,6 @@ export enum LogContext {
AgeAssurance = 'age-assurance',
PolicyUpdate = 'policy-update',
Geolocation = 'geolocation',
GrowthBook = 'growthbook',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+6 -17
View File
@@ -13,8 +13,7 @@ import * as bcp47Match from 'bcp-47-match'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {logger, type Metrics} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search'
@@ -124,7 +123,7 @@ type ExploreScreenItems =
bottomBorder?: boolean
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
metricsTag: Metrics['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed'
}
}
@@ -135,7 +134,7 @@ type ExploreScreenItems =
icon: React.ComponentType<SVGIconProps>
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
metricsTag: Metrics['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed'
}
hideDefaultTab?: boolean
@@ -729,12 +728,7 @@ export function Explore({
<ModuleHeader.SearchButton
{...item.searchButton}
onPress={() =>
focusSearchInput(
(item.searchButton?.tab || 'user') as
| 'user'
| 'profile'
| 'feed',
)
focusSearchInput(item.searchButton?.tab || 'user')
}
/>
)}
@@ -751,12 +745,7 @@ export function Explore({
<ModuleHeader.SearchButton
{...item.searchButton}
onPress={() =>
focusSearchInput(
(item.searchButton?.tab || 'user') as
| 'user'
| 'profile'
| 'feed',
)
focusSearchInput(item.searchButton?.tab || 'user')
}
/>
)}
@@ -1043,7 +1032,7 @@ export function Explore({
const seenProfilesRef = useRef<Set<string>>(new Set())
const onItemSeen = useCallback(
(item: ExploreScreenItems) => {
let module: MetricEvents['explore:module:seen']['module']
let module: Metrics['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type
} else if (item.type === 'profile') {
+2
View File
@@ -170,6 +170,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
SessionApiContext['logoutCurrentAccount']
>(
logContext => {
// TODO
// const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
const prevState = store.getState()