Shared metadata cache

This commit is contained in:
Eric Bailey
2026-01-20 17:44:21 -06:00
parent aadc71bb3b
commit af8fada3c1
20 changed files with 275 additions and 203 deletions
+16 -4
View File
@@ -1,8 +1,6 @@
# Logger
# Logging & Metrics
Simple logger for Bluesky.
## At a Glance
## Logging
```typescript
import { logger, Logger } from '#/logger'
@@ -43,3 +41,17 @@ Debug logs are dev-only, and not enabled by default. Once enabled, they can get
noisy. So you can filter them by setting the `EXPO_PUBLIC_LOG_DEBUG` env var
e.g. `EXPO_PUBLIC_LOG_DEBUG=notifications`. These values can be comma-separated
and include wildcards.
## Metrics
Metrics are emit using `logger.metric(event, payload)`.
## Metadata
We've implemented a shared metadata cache, which is used by the logger and by
our feature-flagging system, GrowthBook.
## Initialization
We manage our own device and session IDs, which are initialized at app startup
via `await setupDeviceId`.
@@ -1,24 +0,0 @@
import uuid from 'react-native-uuid'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {device} from '#/storage'
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
export async function getAndMigrateStableId() {
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
device.set(['stableId'], id)
return id
}
export function getStableId() {
return device.get(['stableId'])
}
export function getStableIdOrThrow() {
const id = device.get(['stableId'])
if (!id) {
throw new Error(`stableId is not set, call getAndMigrateStableId first`)
}
return id
}
@@ -1,10 +0,0 @@
import uuid from 'react-native-uuid'
export * from '#/logger/growthbook/identifiers/common'
// TODO probably want to clear this
const sessionId = uuid.v4()
/**
* Stable session ID, persisted for the duration of the user's session
*/
export const getSessionId = () => sessionId
@@ -1,15 +0,0 @@
import uuid from 'react-native-uuid'
export * from '#/logger/growthbook/identifiers/common'
/**
* Stable session ID, persisted for the duration of the user's session
*/
export const getSessionId = () => {
let id = sessionStorage.getItem('BSKY_SESSION_ID')
if (!id) {
id = uuid.v4()
sessionStorage.setItem('BSKY_SESSION_ID', id)
}
return id
}
+23 -128
View File
@@ -1,18 +1,9 @@
import {useCallback} from 'react'
import {Platform} from 'react-native'
import {GrowthBook} from '@growthbook/growthbook-react'
import {BSKY_SERVICE} from '#/lib/constants'
import {
getAndMigrateStableId,
getSessionId,
getStableId,
} from '#/logger/growthbook/identifiers'
import * as referrer from '#/logger/growthbook/util/referrer'
import * as persisted from '#/state/persisted'
import {type SessionAccount} from '#/state/session'
import {type Metadata} from '#/logger/metadata'
import {metrics} from '#/logger/metrics'
import * as env from '#/env'
import {device} from '#/storage'
const debugEnabled = env.IS_DEV && true
const debug = (message: string, attributes?: Record<string, any>) => {
@@ -28,57 +19,22 @@ const TIMEOUT_PREFER_FRESH_GATES = 1500
/**
* We vary the amount of time we wait for GrowthBook to fetch feature
* gates based on the strategy specified.
*
* TODO examples
*/
type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
/**
* These are fields that are handled specially by GrowthBook
*/
type GrowthBookDefaultAttributes = {
/** Special GrowthBook field */
device_id: string
/** Special GrowthBook field */
session_id: string
}
/**
* These are user fields that are handled specially by GrowthBook
*/
type GrowthBookDefaultUserAttributes = {
/** Special GrowthBook field */
user_id: string
}
type DefaultAttributes = GrowthBookDefaultAttributes & {
/** Custom field provided by our Geolocation context */
country: string
}
type UserAttributes = GrowthBookDefaultUserAttributes & {
// do not use `id`, GrowthBook will think it's the same as `device_id`
did: string
isBskyPds: boolean
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
refSrc: string
refUrl: string
appLanguage: string
contentLanguages: string[]
}
export type Attributes = DefaultAttributes & Partial<UserAttributes>
const gb = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
trackingCallback: (experiment, result) => {
debug(`Experiment Viewed`, {
metrics.track('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
// TODO
},
attributes: getDefaultAttributes(),
/**
* Initial values are set on startup in `#/logger/metdata/index.ts`
*/
attributes: {},
})
/**
@@ -89,16 +45,6 @@ const gb = new GrowthBook({
* completes.
*/
export const initializer = new Promise<void>(async y => {
/*
* This _must_ happen first to ensure continuity of the device ID from
* StatSig to GrowthBook
*/
const id = await getAndMigrateStableId()
const attr: GrowthBookDefaultAttributes = {
device_id: id,
session_id: getSessionId(),
}
gb.setAttributes(attr)
await gb.init({timeout: TIMEOUT_INIT})
y()
})
@@ -107,23 +53,28 @@ export function getGrowthBook() {
return gb
}
export function getGrowthBookAttributes(): Attributes {
return gb.getAttributes() as Attributes
/**
* Converts our metadata into GrowthBook attributes and sets them.
*/
export function setGrowthBookAttributes({
deviceId: device_id,
sessionId: session_id,
...metadata
}: Metadata) {
gb.setAttributes({
device_id, // GrowthBook special field
session_id, // GrowthBook special field
user_id: metadata.did, // GrowthBook special field
...metadata,
})
}
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
*/
export async function refresh({
account,
strategy,
}: {
account?: SessionAccount
strategy: FeatureFetchStrategy
}) {
debug(`refresh`, {account: !!account, strategy})
setAttributesForAccount(account)
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
debug(`refresh`, {strategy})
await gb.refreshFeatures({
timeout:
strategy === 'prefer-low-latency'
@@ -140,59 +91,3 @@ export function useGate() {
return gb.isOn(gate)
}, [])
}
/**
* Get the default attributes that should always be set
* on the GrowthBook instance
*/
function getDefaultAttributes() {
return {
device_id: getStableId() || 'unset',
session_id: getSessionId(),
country: device.get(['mergedGeolocation'])?.countryCode || 'unknown',
}
}
/**
* Set attributes on the global GrowthBook instance. If an account is provided,
* set user attributes as well. Otherwise, clear user attributes.
*/
function setAttributesForAccount(account?: SessionAccount) {
if (account) {
const attr: Attributes = {
...getDefaultAttributes(),
...(getUserAttributes(account) || {}),
}
gb.setAttributes(attr)
debug(`setAttributesForAccount: has account`, {attributes: attr})
} else {
const attr = getDefaultAttributes()
gb.setAttributes(attr)
debug(`setAttributesForAccount: no account`, {attributes: attr})
}
}
/**
* Converts a SessionAccount into user attributes for GrowthBook
*/
export function getUserAttributes(account: SessionAccount): UserAttributes
export function getUserAttributes(account: undefined): null
export function getUserAttributes(
account?: SessionAccount,
): UserAttributes | null {
if (!account) return null
const languagePrefs = persisted.get('languagePrefs')
return {
user_id: account.did,
did: account.did,
isBskyPds: account.service.startsWith(BSKY_SERVICE),
platform: Platform.OS,
appVersion: env.RELEASE_VERSION,
bundleIdentifier: env.BUNDLE_IDENTIFIER,
bundleDate: env.BUNDLE_DATE,
appLanguage: languagePrefs.appLanguage,
contentLanguages: languagePrefs.contentLanguages,
refSrc: referrer.src,
refUrl: referrer.url,
}
}
-2
View File
@@ -1,2 +0,0 @@
export const src = ''
export const url = ''
@@ -1,3 +0,0 @@
const params = new URLSearchParams(window.location.search)
export const src = params.get('ref_src') ?? ''
export const url = decodeURIComponent(params.get('ref_url') ?? '')
+26
View File
@@ -0,0 +1,26 @@
import uuid from 'react-native-uuid'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {device} from '#/storage'
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
export async function getAndMigrateDeviceId() {
const migrated = getDeviceId()
if (migrated) return migrated
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
device.set(['deviceId'], id)
return id
}
export function getDeviceId() {
return device.get(['deviceId'])
}
export function getDeviceIdOrThrow() {
const id = device.get(['deviceId'])
if (!id) {
throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`)
}
return id
}
+104
View File
@@ -0,0 +1,104 @@
import {Platform} from 'react-native'
import {BSKY_SERVICE} from '#/lib/constants'
import {setGrowthBookAttributes} from '#/logger/growthbook'
import {getAndMigrateDeviceId, getDeviceId} from '#/logger/metadata/deviceId'
import {getSessionId} from '#/logger/metadata/sessionId'
import * as persisted from '#/state/persisted'
import * as env from '#/env'
import {device} from '#/storage'
export type BaseMetadata = {
deviceId: string
sessionId: string
country: string
}
export type UserMetadata = {
did: string
isBskyPds: boolean
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
refSrc: string
refUrl: string
appLanguage: string
contentLanguages: string[]
}
export type Metadata = BaseMetadata & Partial<UserMetadata>
/**
* Ensures that deviceId is set and migrated from legacy storage. Handled on
* startup in `App.<platform>.tsx`
*/
export const setupDeviceId = getAndMigrateDeviceId()
let baseMetadata: BaseMetadata = {
deviceId: getDeviceId() || 'unknown',
sessionId: getSessionId(),
country: device.get(['mergedGeolocation'])?.countryCode || 'unknown',
}
export function updateBaseMetadata(
metadata: Omit<BaseMetadata, 'deviceId' | 'sessionId'>,
) {
baseMetadata = {
deviceId: getDeviceId() || 'unknown',
sessionId: getSessionId(),
...metadata,
}
__onMetadataChange()
}
export function getBaseMetadata() {
return {
...baseMetadata,
sessionId: getSessionId(), // may have changed
}
}
let refSrc = ''
let refUrl = ''
if (env.IS_WEB) {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
let userMetadata: UserMetadata | null = null
export function updateUserMetadata(account: persisted.PersistedAccount | null) {
if (account === null) {
userMetadata = null
} else {
const languagePrefs = persisted.get('languagePrefs')
userMetadata = {
did: account.did,
isBskyPds: account.service.startsWith(BSKY_SERVICE),
platform: Platform.OS,
appVersion: env.RELEASE_VERSION,
bundleIdentifier: env.BUNDLE_IDENTIFIER,
bundleDate: env.BUNDLE_DATE,
appLanguage: languagePrefs.appLanguage,
contentLanguages: languagePrefs.contentLanguages,
refSrc,
refUrl,
}
}
__onMetadataChange()
}
export function getUserMetadata() {
return userMetadata
}
export function getMetadata(): Metadata {
return {
...getBaseMetadata(),
...(getUserMetadata() || {}),
}
}
function __onMetadataChange() {
const metadata = getMetadata()
setGrowthBookAttributes(metadata)
}
__onMetadataChange()
+34
View File
@@ -0,0 +1,34 @@
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
import {device} from '#/storage'
const TTL = 5 * 60 * 1e3 // 5 min on native
function expired(since: number | undefined) {
if (since === undefined) return false
return Date.now() - since >= TTL
}
let sessionId = (() => {
const existing = device.get(['nativeSessionId'])
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
const id = existing && !expired(lastEvent) ? existing : uuid.v4()
device.set(['nativeSessionId'], id)
return id
})()
onAppStateChange(state => {
if (state === 'active') {
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
if (expired(lastEvent)) {
sessionId = uuid.v4()
device.set(['nativeSessionId'], sessionId)
}
} else {
device.set(['nativeSessionIdLastEventAt'], Date.now())
}
})
export function getSessionId() {
return sessionId
}
+38
View File
@@ -0,0 +1,38 @@
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
const TTL = 30 * 60 * 1e3 // 30 min on web
const SESSION_ID_KEY = 'bsky_session_id'
const LAST_EVENT_KEY = 'bsky_session_id_last_event_at'
function expired(since: number | undefined) {
if (since === undefined) return false
return Date.now() - since >= TTL
}
let sessionId = (() => {
const existing = window.sessionStorage.getItem(SESSION_ID_KEY)
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
const id = existing && !expired(lastEvent) ? existing : uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, id)
return id
})()
onAppStateChange(state => {
if (state === 'active') {
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
if (expired(lastEvent)) {
sessionId = uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
}
} else {
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
}
})
export function getSessionId() {
return sessionId
}
+4 -7
View File
@@ -1,10 +1,7 @@
import {onAppStateChange} from '#/lib/appState'
import {isNetworkError} from '#/lib/strings/errors'
import {
type Attributes,
getGrowthBook,
getGrowthBookAttributes,
} from '#/logger/growthbook'
import {getGrowthBook} from '#/logger/growthbook'
import {getMetadata, type Metadata} from '#/logger/metadata'
import {type Metrics} from '#/logger/metrics/events'
import {Sentry} from '#/logger/sentry/lib'
import * as env from '#/env'
@@ -13,7 +10,7 @@ type Event<M extends Metrics> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Attributes
metadata: Metadata
}
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/track'
@@ -47,7 +44,7 @@ export class MetricsClient {
time: Date.now(),
event,
payload,
metadata: getGrowthBookAttributes(),
metadata: getMetadata(),
})
if (this.queue.length > 100) {
+5
View File
@@ -7,6 +7,11 @@ export type Metrics = {
init: {
initMs: number
}
'experiment:viewed': {
experimentId: string
variationId: string
}
'account:loggedIn': {
logContext:
| 'LoginForm'