From 5b8f8bb2346d29ecd696c883309183c5dbb99f8e Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 20 Jan 2026 15:36:16 -0600 Subject: [PATCH] Add metrics client --- .env.example | 3 + src/components/FeedInterstitials.tsx | 14 +- src/components/PostControls/DiscoverDebug.tsx | 4 +- src/env/common.ts | 10 +- src/lib/appState.ts | 25 ++++ src/lib/hooks/useAppState.ts | 19 +-- src/lib/hooks/usePostViewTracking.ts | 5 +- src/lib/statsig/statsig.tsx | 13 +- src/logger/growthbook/index.tsx | 38 +++--- src/logger/index.tsx | 16 +-- src/logger/{metrics.ts => metrics/events.ts} | 4 +- src/logger/metrics/index.ts | 124 ++++++++++++++++++ src/logger/types.ts | 1 - src/screens/Search/Explore.tsx | 23 +--- src/state/session/index.tsx | 2 + 15 files changed, 217 insertions(+), 84 deletions(-) create mode 100644 src/lib/appState.ts rename src/logger/{metrics.ts => metrics/events.ts} (99%) create mode 100644 src/logger/metrics/index.ts diff --git a/.env.example b/.env.example index 65fb601353..df3456410b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 14887adb7c..880dcd8643 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -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>(new Set()) const containerRef = useRef(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(() => { diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx index 02d852003f..88aedae07d 100644 --- a/src/components/PostControls/DiscoverDebug.tsx +++ b/src/components/PostControls/DiscoverDebug.tsx @@ -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`) }}> diff --git a/src/env/common.ts b/src/env/common.ts index ec3a0e9f25..5daec5e577 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -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 diff --git a/src/lib/appState.ts b/src/lib/appState.ts new file mode 100644 index 0000000000..1c363c57c0 --- /dev/null +++ b/src/lib/appState.ts @@ -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 +} diff --git a/src/lib/hooks/useAppState.ts b/src/lib/hooks/useAppState.ts index 7fb228d618..58290ca322 100644 --- a/src/lib/hooks/useAppState.ts +++ b/src/lib/hooks/useAppState.ts @@ -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 diff --git a/src/lib/hooks/usePostViewTracking.ts b/src/lib/hooks/usePostViewTracking.ts index b9f4d0eb02..f67398e826 100644 --- a/src/lib/hooks/usePostViewTracking.ts +++ b/src/lib/hooks/usePostViewTracking.ts @@ -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()) diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 65de654ac7..da00d707e6 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -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( +export function logEvent( 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( } } -function toStringRecord( - metadata: MetricEvents[E] & FlatJSONRecord, +function toStringRecord( + metadata: Metrics[E] & FlatJSONRecord, ): Record { const record: Record = {} for (let key in metadata) { diff --git a/src/logger/growthbook/index.tsx b/src/logger/growthbook/index.tsx index 8cfc081cce..dea3a70eb9 100644 --- a/src/logger/growthbook/index.tsx +++ b/src/logger/growthbook/index.tsx @@ -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) => { + 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 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(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) { - 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}) } } diff --git a/src/logger/index.tsx b/src/logger/index.tsx index 8e2a9b1a6c..a503d9bce0 100644 --- a/src/logger/index.tsx +++ b/src/logger/index.tsx @@ -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( + metric( 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()) } diff --git a/src/logger/metrics.ts b/src/logger/metrics/events.ts similarity index 99% rename from src/logger/metrics.ts rename to src/logger/metrics/events.ts index 7afe7f3bbe..864e07ec89 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics/events.ts @@ -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' diff --git a/src/logger/metrics/index.ts b/src/logger/metrics/index.ts new file mode 100644 index 0000000000..22bb2d946c --- /dev/null +++ b/src/logger/metrics/index.ts @@ -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 = { + 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[] = [] + private failedQueue: Event[] = [] + 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(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[], 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), + }) + } +}) diff --git a/src/logger/types.ts b/src/logger/types.ts index cf68cc1e01..19e12c5045 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -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 diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 6b49588db9..6764ac2005 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -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 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({ - focusSearchInput( - (item.searchButton?.tab || 'user') as - | 'user' - | 'profile' - | 'feed', - ) + focusSearchInput(item.searchButton?.tab || 'user') } /> )} @@ -751,12 +745,7 @@ export function Explore({ - focusSearchInput( - (item.searchButton?.tab || 'user') as - | 'user' - | 'profile' - | 'feed', - ) + focusSearchInput(item.searchButton?.tab || 'user') } /> )} @@ -1043,7 +1032,7 @@ export function Explore({ const seenProfilesRef = useRef>(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') { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 88f2df4b8b..4ff7010f87 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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()