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 # Growthbook config
EXPO_PUBLIC_GROWTHBOOK_API_HOST= EXPO_PUBLIC_GROWTHBOOK_API_HOST=
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= 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 {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger, type Metrics} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -450,12 +449,11 @@ export function ProfileGrid({
const seenProfilesRef = useRef<Set<string>>(new Set()) const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null) const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false) const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] = const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
isFeedContext ? 'InterstitialDiscover'
? 'InterstitialDiscover' : isProfileHeaderContext
: isProfileHeaderContext ? 'Profile'
? 'Profile' : 'InterstitialProfile'
: 'InterstitialProfile'
// Callback to fire seen events // Callback to fire seen events
const fireSeen = useCallback(() => { const fireSeen = useCallback(() => {
@@ -4,7 +4,7 @@ import {t} from '@lingui/macro'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/logger/growthbook' import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Toast from '#/components/Toast' 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]} style={[a.absolute, {zIndex: 1000, maxWidth: 65, bottom: -4}, a.left_0]}
onPress={e => { onPress={e => {
e.stopPropagation() e.stopPropagation()
logEvent('discover_debug:copy_feed_context', {feedContext}) logger.metric('debug', {feedContext})
Clipboard.setStringAsync(feedContext) Clipboard.setStringAsync(feedContext)
Toast.show(t`Copied to clipboard`) 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 = export const CHAT_PROXY_DID: Did =
process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' 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 * Growthbook API host
*/ */
export const GROWTHBOOK_API_HOST: string | undefined = export const GROWTHBOOK_API_HOST: string =
process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb`
/** /**
* Growthbook client key * 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 {useAppState as useAppStateBase} from '#/lib/appState'
import {AppState} from 'react-native'
export function useAppState() { /**
const [state, setState] = useState(AppState.currentState) * @deprecated use `useAppState` from `#/lib/appState` instead
*/
useEffect(() => { export const useAppState = useAppStateBase
const sub = AppState.addEventListener('change', nextAppState => {
setState(nextAppState)
})
return () => sub.remove()
}, [])
return state
}
+2 -3
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef} from 'react' import {useCallback, useRef} from 'react'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {logger} from '#/logger' import {logger, type Metrics} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
/** /**
* Hook that returns a callback to track post:view events. * 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 * @returns A callback that accepts a post and logs the view event
*/ */
export function usePostViewTracking( export function usePostViewTracking(
logContext: MetricEvents['post:view']['logContext'], logContext: Metrics['post:view']['logContext'],
) { ) {
const seenUrisRef = useRef(new Set<string>()) 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 {AppState, type AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo' import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {logger} from '#/logger' import {logger, type Metrics} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import * as env from '#/env' import * as env from '#/env'
@@ -43,7 +42,7 @@ if (IS_WEB && typeof window !== 'undefined') {
refUrl = decodeURIComponent(params.get('ref_url') ?? '') refUrl = decodeURIComponent(params.get('ref_url') ?? '')
} }
export type {MetricEvents as LogEvents} export type {Metrics as LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) { function createStatsigOptions(prefetchUsers: StatsigUser[]) {
return { return {
@@ -94,9 +93,9 @@ export function toClout(n: number | null | undefined): number | undefined {
/** /**
* @deprecated use `logger.metric()` instead * @deprecated use `logger.metric()` instead
*/ */
export function logEvent<E extends keyof MetricEvents>( export function logEvent<E extends keyof Metrics>(
eventName: E & string, eventName: E & string,
rawMetadata: MetricEvents[E] & FlatJSONRecord, rawMetadata: Metrics[E] & FlatJSONRecord,
options: { options: {
/** /**
* Send to our data lake only, not to StatSig * 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>( function toStringRecord<E extends keyof Metrics>(
metadata: MetricEvents[E] & FlatJSONRecord, metadata: Metrics[E] & FlatJSONRecord,
): Record<string, string> { ): Record<string, string> {
const record: Record<string, string> = {} const record: Record<string, string> = {}
for (let key in metadata) { for (let key in metadata) {
+21 -17
View File
@@ -1,6 +1,5 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {growthbookTrackingPlugin} from '@growthbook/growthbook/plugins'
import {GrowthBook} from '@growthbook/growthbook-react' import {GrowthBook} from '@growthbook/growthbook-react'
import {BSKY_SERVICE} from '#/lib/constants' import {BSKY_SERVICE} from '#/lib/constants'
@@ -15,6 +14,13 @@ import {type SessionAccount} from '#/state/session'
import * as env from '#/env' import * as env from '#/env'
import {device} from '#/storage' 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_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250 const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500 const TIMEOUT_PREFER_FRESH_GATES = 1500
@@ -60,21 +66,17 @@ type UserAttributes = GrowthBookDefaultUserAttributes & {
appLanguage: string appLanguage: string
contentLanguages: string[] contentLanguages: string[]
} }
type Attributes = DefaultAttributes & UserAttributes export type Attributes = DefaultAttributes & Partial<UserAttributes>
const gb = new GrowthBook({ const gb = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST, apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY, clientKey: env.GROWTHBOOK_CLIENT_KEY,
plugins: [growthbookTrackingPlugin()],
trackingCallback: (experiment, result) => { trackingCallback: (experiment, result) => {
console.debug('Experiment Viewed', { debug(`Experiment Viewed`, {
experimentId: experiment.key,
variationId: result.key,
})
gb.logEvent('Experiment Viewed', {
experimentId: experiment.key, experimentId: experiment.key,
variationId: result.key, variationId: result.key,
}) })
// TODO
}, },
attributes: getDefaultAttributes(), attributes: getDefaultAttributes(),
}) })
@@ -101,6 +103,14 @@ export const initializer = new Promise<void>(async y => {
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 * Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any. * provided account, if any.
@@ -112,6 +122,7 @@ export async function refresh({
account?: SessionAccount account?: SessionAccount
strategy: FeatureFetchStrategy strategy: FeatureFetchStrategy
}) { }) {
debug(`refresh`, {account: !!account, strategy})
setAttributesForAccount(account) setAttributesForAccount(account)
await gb.refreshFeatures({ await gb.refreshFeatures({
timeout: 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 * Hook to check if a feature gate is enabled
*/ */
@@ -160,11 +164,11 @@ function setAttributesForAccount(account?: SessionAccount) {
...(getUserAttributes(account) || {}), ...(getUserAttributes(account) || {}),
} }
gb.setAttributes(attr) gb.setAttributes(attr)
console.debug(`setAttributesForAccount: has account`, {attributes: attr}) debug(`setAttributesForAccount: has account`, {attributes: attr})
} else { } else {
const attr = getDefaultAttributes() const attr = getDefaultAttributes()
gb.setAttributes(attr) 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 {nanoid} from 'nanoid/non-secure'
import {logEvent} from '#/logger/growthbook'
import {add} from '#/logger/logDump' 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 {consoleTransport} from '#/logger/transports/console'
import {sentryTransport} from '#/logger/transports/sentry' import {sentryTransport} from '#/logger/transports/sentry'
import { import {
@@ -14,7 +13,7 @@ import {
import {enabledLogLevels} from '#/logger/util' import {enabledLogLevels} from '#/logger/util'
import {ENV} from '#/env' import {ENV} from '#/env'
export {type MetricEvents as Metrics} from '#/logger/metrics' export {type Metrics} from '#/logger/metrics'
const TRANSPORTS: Transport[] = (function configureTransports() { const TRANSPORTS: Transport[] = (function configureTransports() {
switch (ENV) { switch (ENV) {
@@ -95,20 +94,17 @@ export class Logger {
this.transport({level: LogLevel.Error, message: error, metadata}) this.transport({level: LogLevel.Error, message: error, metadata})
} }
metric<E extends keyof MetricEvents>( metric<E extends keyof Metrics>(
event: E & string, event: E & string,
metadata: MetricEvents[E], metadata: Metrics[E],
options: { _: {
/** /**
* Optionally also send to StatSig * Optionally also send to StatSig
*/ */
statsig?: boolean statsig?: boolean
} = {statsig: true}, } = {statsig: true},
) { ) {
logEvent(event, metadata, { metrics.track(event, metadata)
lake: !options.statsig,
})
for (const transport of this.transports) { for (const transport of this.transports) {
transport(LogLevel.Info, LogContext.Metric, event, metadata, Date.now()) 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 FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = { export type Metrics = {
// App events // App events
init: { init: {
initMs: number initMs: number
@@ -374,7 +374,6 @@ export type MetricEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
@@ -468,7 +467,6 @@ export type MetricEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | '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', AgeAssurance = 'age-assurance',
PolicyUpdate = 'policy-update', PolicyUpdate = 'policy-update',
Geolocation = 'geolocation', Geolocation = 'geolocation',
GrowthBook = 'growthbook',
/** /**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this * 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 {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger, type Metrics} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useLanguagePrefs} from '#/state/preferences/languages' import {useLanguagePrefs} from '#/state/preferences/languages'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search'
@@ -124,7 +123,7 @@ type ExploreScreenItems =
bottomBorder?: boolean bottomBorder?: boolean
searchButton?: { searchButton?: {
label: string label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] metricsTag: Metrics['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed' tab: 'user' | 'profile' | 'feed'
} }
} }
@@ -135,7 +134,7 @@ type ExploreScreenItems =
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
searchButton?: { searchButton?: {
label: string label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] metricsTag: Metrics['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed' tab: 'user' | 'profile' | 'feed'
} }
hideDefaultTab?: boolean hideDefaultTab?: boolean
@@ -729,12 +728,7 @@ export function Explore({
<ModuleHeader.SearchButton <ModuleHeader.SearchButton
{...item.searchButton} {...item.searchButton}
onPress={() => onPress={() =>
focusSearchInput( focusSearchInput(item.searchButton?.tab || 'user')
(item.searchButton?.tab || 'user') as
| 'user'
| 'profile'
| 'feed',
)
} }
/> />
)} )}
@@ -751,12 +745,7 @@ export function Explore({
<ModuleHeader.SearchButton <ModuleHeader.SearchButton
{...item.searchButton} {...item.searchButton}
onPress={() => onPress={() =>
focusSearchInput( focusSearchInput(item.searchButton?.tab || 'user')
(item.searchButton?.tab || 'user') as
| 'user'
| 'profile'
| 'feed',
)
} }
/> />
)} )}
@@ -1043,7 +1032,7 @@ export function Explore({
const seenProfilesRef = useRef<Set<string>>(new Set()) const seenProfilesRef = useRef<Set<string>>(new Set())
const onItemSeen = useCallback( const onItemSeen = useCallback(
(item: ExploreScreenItems) => { (item: ExploreScreenItems) => {
let module: MetricEvents['explore:module:seen']['module'] let module: Metrics['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type module = item.type
} else if (item.type === 'profile') { } else if (item.type === 'profile') {
+2
View File
@@ -170,6 +170,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
SessionApiContext['logoutCurrentAccount'] SessionApiContext['logoutCurrentAccount']
>( >(
logContext => { logContext => {
// TODO
// const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
addSessionDebugLog({type: 'method:start', method: 'logout'}) addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask() cancelPendingTask()
const prevState = store.getState() const prevState = store.getState()