Shared metadata cache
This commit is contained in:
+4
-2
@@ -23,6 +23,7 @@ import {ThemeProvider} from '#/lib/ThemeContext'
|
|||||||
import I18nProvider from '#/locale/i18nProvider'
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {initializer as growthbookInitializer} from '#/logger/growthbook'
|
import {initializer as growthbookInitializer} from '#/logger/growthbook'
|
||||||
|
import {setupDeviceId} from '#/logger/metadata'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
@@ -112,9 +113,10 @@ function InnerApp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onLaunch(account?: SessionAccount) {
|
async function onLaunch(account?: SessionAccount) {
|
||||||
try {
|
try {
|
||||||
await growthbookInitializer
|
|
||||||
if (account) {
|
if (account) {
|
||||||
await resumeSession(account)
|
await resumeSession(account)
|
||||||
|
} else {
|
||||||
|
await growthbookInitializer
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`session: resume failed`, {message: e})
|
logger.error(`session: resume failed`, {message: e})
|
||||||
@@ -208,7 +210,7 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||||
setReady(true),
|
setReady(true),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
+4
-2
@@ -14,6 +14,7 @@ import {ThemeProvider} from '#/lib/ThemeContext'
|
|||||||
import I18nProvider from '#/locale/i18nProvider'
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {initializer as growthbookInitializer} from '#/logger/growthbook'
|
import {initializer as growthbookInitializer} from '#/logger/growthbook'
|
||||||
|
import {setupDeviceId} from '#/logger/metadata'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
@@ -88,9 +89,10 @@ function InnerApp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onLaunch(account?: SessionAccount) {
|
async function onLaunch(account?: SessionAccount) {
|
||||||
try {
|
try {
|
||||||
await growthbookInitializer
|
|
||||||
if (account) {
|
if (account) {
|
||||||
await resumeSession(account)
|
await resumeSession(account)
|
||||||
|
} else {
|
||||||
|
await growthbookInitializer
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`session: resumeSession failed`, {message: e})
|
logger.error(`session: resumeSession failed`, {message: e})
|
||||||
@@ -183,7 +185,7 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||||
setReady(true),
|
setReady(true),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 {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 +32,6 @@ 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()
|
||||||
logger.metric('debug', {feedContext})
|
|
||||||
Clipboard.setStringAsync(feedContext)
|
Clipboard.setStringAsync(feedContext)
|
||||||
Toast.show(t`Copied to clipboard`)
|
Toast.show(t`Copied to clipboard`)
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
import {updateBaseMetadata} from '#/logger/metadata'
|
||||||
import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
|
import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
|
||||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||||
import {type Geolocation} from '#/geolocation/types'
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
@@ -53,6 +54,9 @@ export function Provider({children}: {children: ReactNode}) {
|
|||||||
* Needs to be available for the data prefetching we do on boot.
|
* Needs to be available for the data prefetching we do on boot.
|
||||||
*/
|
*/
|
||||||
device.set(['mergedGeolocation'], geolocation)
|
device.set(['mergedGeolocation'], geolocation)
|
||||||
|
updateBaseMetadata({
|
||||||
|
country: geolocation.countryCode || 'unknown',
|
||||||
|
})
|
||||||
}, [geolocation])
|
}, [geolocation])
|
||||||
|
|
||||||
useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
|
useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function onAppStateChange(cb: (state: AppStateStatus) => void) {
|
|||||||
let prev = AppState.currentState
|
let prev = AppState.currentState
|
||||||
return AppState.addEventListener('change', next => {
|
return AppState.addEventListener('change', next => {
|
||||||
if (next === prev) return
|
if (next === prev) return
|
||||||
|
prev = next
|
||||||
cb(next)
|
cb(next)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -1,8 +1,6 @@
|
|||||||
# Logger
|
# Logging & Metrics
|
||||||
|
|
||||||
Simple logger for Bluesky.
|
## Logging
|
||||||
|
|
||||||
## At a Glance
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { logger, Logger } from '#/logger'
|
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
|
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
|
e.g. `EXPO_PUBLIC_LOG_DEBUG=notifications`. These values can be comma-separated
|
||||||
and include wildcards.
|
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
@@ -1,18 +1,9 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {Platform} from 'react-native'
|
|
||||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||||
|
|
||||||
import {BSKY_SERVICE} from '#/lib/constants'
|
import {type Metadata} from '#/logger/metadata'
|
||||||
import {
|
import {metrics} from '#/logger/metrics'
|
||||||
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 * as env from '#/env'
|
import * as env from '#/env'
|
||||||
import {device} from '#/storage'
|
|
||||||
|
|
||||||
const debugEnabled = env.IS_DEV && true
|
const debugEnabled = env.IS_DEV && true
|
||||||
const debug = (message: string, attributes?: Record<string, any>) => {
|
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
|
* We vary the amount of time we wait for GrowthBook to fetch feature
|
||||||
* gates based on the strategy specified.
|
* gates based on the strategy specified.
|
||||||
*
|
|
||||||
* TODO examples
|
|
||||||
*/
|
*/
|
||||||
type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
|
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({
|
const gb = new GrowthBook({
|
||||||
apiHost: env.GROWTHBOOK_API_HOST,
|
apiHost: env.GROWTHBOOK_API_HOST,
|
||||||
clientKey: env.GROWTHBOOK_CLIENT_KEY,
|
clientKey: env.GROWTHBOOK_CLIENT_KEY,
|
||||||
trackingCallback: (experiment, result) => {
|
trackingCallback: (experiment, result) => {
|
||||||
debug(`Experiment Viewed`, {
|
metrics.track('experiment:viewed', {
|
||||||
experimentId: experiment.key,
|
experimentId: experiment.key,
|
||||||
variationId: result.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.
|
* completes.
|
||||||
*/
|
*/
|
||||||
export const initializer = new Promise<void>(async y => {
|
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})
|
await gb.init({timeout: TIMEOUT_INIT})
|
||||||
y()
|
y()
|
||||||
})
|
})
|
||||||
@@ -107,23 +53,28 @@ export function getGrowthBook() {
|
|||||||
return gb
|
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
|
* Refresh feature gates from GrowthBook. Updates attributes based on the
|
||||||
* provided account, if any.
|
* provided account, if any.
|
||||||
*/
|
*/
|
||||||
export async function refresh({
|
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
||||||
account,
|
debug(`refresh`, {strategy})
|
||||||
strategy,
|
|
||||||
}: {
|
|
||||||
account?: SessionAccount
|
|
||||||
strategy: FeatureFetchStrategy
|
|
||||||
}) {
|
|
||||||
debug(`refresh`, {account: !!account, strategy})
|
|
||||||
setAttributesForAccount(account)
|
|
||||||
await gb.refreshFeatures({
|
await gb.refreshFeatures({
|
||||||
timeout:
|
timeout:
|
||||||
strategy === 'prefer-low-latency'
|
strategy === 'prefer-low-latency'
|
||||||
@@ -140,59 +91,3 @@ export function useGate() {
|
|||||||
return gb.isOn(gate)
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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') ?? '')
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import {onAppStateChange} from '#/lib/appState'
|
import {onAppStateChange} from '#/lib/appState'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {
|
import {getGrowthBook} from '#/logger/growthbook'
|
||||||
type Attributes,
|
import {getMetadata, type Metadata} from '#/logger/metadata'
|
||||||
getGrowthBook,
|
|
||||||
getGrowthBookAttributes,
|
|
||||||
} from '#/logger/growthbook'
|
|
||||||
import {type Metrics} from '#/logger/metrics/events'
|
import {type Metrics} from '#/logger/metrics/events'
|
||||||
import {Sentry} from '#/logger/sentry/lib'
|
import {Sentry} from '#/logger/sentry/lib'
|
||||||
import * as env from '#/env'
|
import * as env from '#/env'
|
||||||
@@ -13,7 +10,7 @@ type Event<M extends Metrics> = {
|
|||||||
time: number
|
time: number
|
||||||
event: keyof M
|
event: keyof M
|
||||||
payload: M[keyof M]
|
payload: M[keyof M]
|
||||||
metadata: Attributes
|
metadata: Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/track'
|
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/track'
|
||||||
@@ -47,7 +44,7 @@ export class MetricsClient {
|
|||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
event,
|
event,
|
||||||
payload,
|
payload,
|
||||||
metadata: getGrowthBookAttributes(),
|
metadata: getMetadata(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.queue.length > 100) {
|
if (this.queue.length > 100) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export type Metrics = {
|
|||||||
init: {
|
init: {
|
||||||
initMs: number
|
initMs: number
|
||||||
}
|
}
|
||||||
|
'experiment:viewed': {
|
||||||
|
experimentId: string
|
||||||
|
variationId: string
|
||||||
|
}
|
||||||
|
|
||||||
'account:loggedIn': {
|
'account:loggedIn': {
|
||||||
logContext:
|
logContext:
|
||||||
| 'LoginForm'
|
| 'LoginForm'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {refresh as refreshGates} from '#/logger/growthbook'
|
import {refresh as refreshGates} from '#/logger/growthbook'
|
||||||
|
import {updateUserMetadata} from '#/logger/metadata'
|
||||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||||
import {
|
import {
|
||||||
@@ -63,8 +64,8 @@ export async function createAgentAndResume(
|
|||||||
if (storedAccount.pdsUrl) {
|
if (storedAccount.pdsUrl) {
|
||||||
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
||||||
}
|
}
|
||||||
|
updateUserMetadata(storedAccount)
|
||||||
const gates = refreshGates({
|
const gates = refreshGates({
|
||||||
account: storedAccount,
|
|
||||||
strategy: 'prefer-low-latency',
|
strategy: 'prefer-low-latency',
|
||||||
})
|
})
|
||||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
const moderation = configureModerationForAccount(agent, storedAccount)
|
||||||
@@ -126,7 +127,8 @@ export async function createAgentAndLogin(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const account = agentToSessionAccountOrThrow(agent)
|
const account = agentToSessionAccountOrThrow(agent)
|
||||||
const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
|
updateUserMetadata(account)
|
||||||
|
const gates = refreshGates({strategy: 'prefer-fresh-gates'})
|
||||||
const moderation = configureModerationForAccount(agent, account)
|
const moderation = configureModerationForAccount(agent, account)
|
||||||
const aa = prefetchAgeAssuranceData({agent})
|
const aa = prefetchAgeAssuranceData({agent})
|
||||||
|
|
||||||
@@ -174,7 +176,8 @@ export async function createAgentAndCreateAccount(
|
|||||||
verificationCode,
|
verificationCode,
|
||||||
})
|
})
|
||||||
const account = agentToSessionAccountOrThrow(agent)
|
const account = agentToSessionAccountOrThrow(agent)
|
||||||
const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
|
updateUserMetadata(account)
|
||||||
|
const gates = refreshGates({strategy: 'prefer-fresh-gates'})
|
||||||
const moderation = configureModerationForAccount(agent, account)
|
const moderation = configureModerationForAccount(agent, account)
|
||||||
|
|
||||||
const createdAt = new Date().toISOString()
|
const createdAt = new Date().toISOString()
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export type Device = {
|
|||||||
* Formerly managed by StatSig, this is the migrated stable ID for the
|
* Formerly managed by StatSig, this is the migrated stable ID for the
|
||||||
* device, used with our logging and metrics tracking.
|
* device, used with our logging and metrics tracking.
|
||||||
*/
|
*/
|
||||||
stableId: string | undefined
|
deviceId?: string
|
||||||
|
/**
|
||||||
|
* Session ID storage for _native only_. On web, use we `sessionStorage`
|
||||||
|
*/
|
||||||
|
nativeSessionId?: string
|
||||||
|
nativeSessionIdLastEventAt?: number
|
||||||
|
|
||||||
fontScale: '-2' | '-1' | '0' | '1' | '2'
|
fontScale: '-2' | '-1' | '0' | '1' | '2'
|
||||||
fontFamily: 'system' | 'theme'
|
fontFamily: 'system' | 'theme'
|
||||||
|
|||||||
Reference in New Issue
Block a user