diff --git a/src/App.native.tsx b/src/App.native.tsx
index 89714d0b11..4cc30cca98 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -23,7 +23,6 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {initializer as growthbookInitializer} from '#/logger/growthbook'
-import {setupDeviceId} from '#/logger/metadata'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -71,6 +70,7 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
+import {AnalyticsContext, setupDeviceId} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {
prefetchLiveEvents,
@@ -228,30 +228,32 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index dfe4c3cb18..48a79b1aae 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -14,7 +14,6 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {initializer as growthbookInitializer} from '#/logger/growthbook'
-import {setupDeviceId} from '#/logger/metadata'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -61,6 +60,7 @@ import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
+import {AnalyticsContext, setupDeviceId} from '#/analytics'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -202,25 +202,27 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/analytics/identifiers/device.ts b/src/analytics/identifiers/device.ts
new file mode 100644
index 0000000000..ac50143548
--- /dev/null
+++ b/src/analytics/identifiers/device.ts
@@ -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
+}
diff --git a/src/analytics/identifiers/index.ts b/src/analytics/identifiers/index.ts
new file mode 100644
index 0000000000..b9b9786f89
--- /dev/null
+++ b/src/analytics/identifiers/index.ts
@@ -0,0 +1,2 @@
+export * from '#/analytics/identifiers/device'
+export * from '#/analytics/identifiers/session'
diff --git a/src/analytics/identifiers/session.ts b/src/analytics/identifiers/session.ts
new file mode 100644
index 0000000000..2828bafefd
--- /dev/null
+++ b/src/analytics/identifiers/session.ts
@@ -0,0 +1,40 @@
+import {useEffect, useState} from 'react'
+import uuid from 'react-native-uuid'
+
+import {onAppStateChange} from '#/lib/appState'
+import {isSessionIdExpired} from '#/analytics/identifiers/util'
+import {device} from '#/storage'
+
+let sessionId = (() => {
+ const existing = device.get(['nativeSessionId'])
+ const lastEvent = device.get(['nativeSessionIdLastEventAt'])
+ const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
+ device.set(['nativeSessionId'], id)
+ return id
+})()
+
+export function getInitialSessionId() {
+ return sessionId
+}
+
+export function useSessionId() {
+ const [id, setId] = useState(() => sessionId)
+
+ useEffect(() => {
+ const sub = onAppStateChange(state => {
+ if (state === 'active') {
+ const lastEvent = device.get(['nativeSessionIdLastEventAt'])
+ if (isSessionIdExpired(lastEvent)) {
+ sessionId = uuid.v4()
+ device.set(['nativeSessionId'], sessionId)
+ setId(sessionId)
+ }
+ } else {
+ device.set(['nativeSessionIdLastEventAt'], Date.now())
+ }
+ })
+ return () => sub.remove()
+ }, [])
+
+ return id
+}
diff --git a/src/analytics/identifiers/session.web.ts b/src/analytics/identifiers/session.web.ts
new file mode 100644
index 0000000000..2fc68ce83d
--- /dev/null
+++ b/src/analytics/identifiers/session.web.ts
@@ -0,0 +1,44 @@
+import {useEffect, useState} from 'react'
+import uuid from 'react-native-uuid'
+
+import {onAppStateChange} from '#/lib/appState'
+import {isSessionIdExpired} from '#/analytics/identifiers/util'
+
+const SESSION_ID_KEY = 'bsky_session_id'
+const LAST_EVENT_KEY = 'bsky_session_id_last_event_at'
+
+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 && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
+ window.sessionStorage.setItem(SESSION_ID_KEY, id)
+ return id
+})()
+
+export function getInitialSessionId() {
+ return sessionId
+}
+
+export function useSessionId() {
+ const [id, setId] = useState(() => sessionId)
+
+ useEffect(() => {
+ const sub = onAppStateChange(state => {
+ if (state === 'active') {
+ const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
+ const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
+ if (isSessionIdExpired(lastEvent)) {
+ sessionId = uuid.v4()
+ window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
+ setId(sessionId)
+ }
+ } else {
+ window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
+ }
+ })
+ return () => sub.remove()
+ }, [])
+
+ return id
+}
diff --git a/src/analytics/identifiers/util.ts b/src/analytics/identifiers/util.ts
new file mode 100644
index 0000000000..2d68f9b902
--- /dev/null
+++ b/src/analytics/identifiers/util.ts
@@ -0,0 +1,9 @@
+import * as env from '#/env'
+
+const ONE_MIN = 60 * 1e3
+const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native
+
+export function isSessionIdExpired(since: number | undefined) {
+ if (since === undefined) return false
+ return Date.now() - since >= TTL
+}
diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx
new file mode 100644
index 0000000000..706d37bda3
--- /dev/null
+++ b/src/analytics/index.tsx
@@ -0,0 +1,93 @@
+import {createContext, useContext, useMemo} from 'react'
+import {Platform} from 'react-native'
+
+import {
+ getAndMigrateDeviceId,
+ getDeviceIdOrThrow,
+ getInitialSessionId,
+ useSessionId,
+} from '#/analytics/identifiers'
+import {type Metrics, metrics} from '#/analytics/metrics'
+import * as referrer from '#/analytics/misc/referrer'
+import {type MergeableMetadata, type Metadata} from '#/analytics/types'
+import * as env from '#/env'
+import {useGeolocation} from '#/geolocation'
+import {device} from '#/storage'
+
+export * as utils from '#/analytics/utils'
+
+type ContextType = {
+ metric: (
+ event: E,
+ payload: Metrics[E],
+ metadata?: MergeableMetadata,
+ ) => void
+ metadata: Metadata
+}
+
+const Context = createContext({
+ metric: (event, payload, metadata) => {
+ metrics.track(event, payload, metadata)
+ },
+ metadata: {
+ base: {
+ deviceId: getDeviceIdOrThrow() ?? 'unknown',
+ sessionId: getInitialSessionId(),
+ platform: Platform.OS,
+ appVersion: env.APP_VERSION,
+ bundleIdentifier: env.BUNDLE_IDENTIFIER,
+ bundleDate: env.BUNDLE_DATE,
+ referrerSrc: referrer.src,
+ referrerUrl: referrer.url,
+ },
+ geolocation: device.get(['mergedGeolocation']) || {
+ countryCode: '',
+ regionCode: '',
+ },
+ },
+})
+
+export const setupDeviceId = getAndMigrateDeviceId()
+
+export function AnalyticsContext({
+ children,
+ metadata,
+}: {
+ children: React.ReactNode
+ metadata?: MergeableMetadata
+}) {
+ if (metadata) {
+ // @ts-ignore
+ if (metadata.__meta !== true) {
+ throw new Error(
+ 'Use the meta() helper when passing metadata to AnalyticsContext',
+ )
+ }
+ }
+ const sessionId = useSessionId()
+ const geolocation = useGeolocation()
+ const parentContext = useContext(Context)
+ const childContext = useMemo(() => {
+ const combinedMetadata = {
+ ...parentContext.metadata,
+ ...metadata,
+ }
+ combinedMetadata.base.sessionId = sessionId
+ combinedMetadata.geolocation = geolocation
+ const context: ContextType = {
+ metadata: combinedMetadata,
+ metric: (event, payload, extraMetadata) => {
+ parentContext.metric(event, payload, {
+ ...combinedMetadata,
+ ...extraMetadata,
+ })
+ },
+ }
+ return context
+ }, [sessionId, geolocation, parentContext, metadata])
+ return {children}
+}
+
+export function useAnalytics() {
+ return useContext(Context)
+}
diff --git a/src/analytics/metrics/client.ts b/src/analytics/metrics/client.ts
new file mode 100644
index 0000000000..c882636d97
--- /dev/null
+++ b/src/analytics/metrics/client.ts
@@ -0,0 +1,108 @@
+import {onAppStateChange} from '#/lib/appState'
+import {isNetworkError} from '#/lib/strings/errors'
+import {Sentry} from '#/logger/sentry/lib'
+import * as env from '#/env'
+
+// TODO just fucken use logger in here
+
+type Event> = {
+ time: number
+ event: keyof M
+ payload: M[keyof M]
+ metadata: Record
+}
+
+const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/track'
+
+export class MetricsClient> {
+ private started: boolean = false
+ private queue: Event[] = []
+ private failedQueue: Event[] = []
+ private flushInterval: NodeJS.Timeout | null = null
+
+ start() {
+ if (this.started) 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: M[E],
+ metadata: Record = {},
+ ) {
+ this.start()
+
+ this.queue.push({
+ time: Date.now(),
+ event,
+ payload,
+ metadata,
+ })
+
+ 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) {
+ navigator.sendBeacon(
+ TRACKING_ENDPOINT,
+ new Blob([body], {type: 'application/json'}),
+ )
+ } 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)
+ }
+}
diff --git a/src/analytics/metrics/index.ts b/src/analytics/metrics/index.ts
new file mode 100644
index 0000000000..dc64ec6f63
--- /dev/null
+++ b/src/analytics/metrics/index.ts
@@ -0,0 +1,20 @@
+import {MetricsClient} from '#/analytics/metrics/client'
+import {type Events} from '#/analytics/metrics/types'
+
+export type {Events as Metrics} from '#/analytics/metrics/types'
+export const metrics = new MetricsClient()
+
+/*
+ * TODO
+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/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
new file mode 100644
index 0000000000..057bf090c4
--- /dev/null
+++ b/src/analytics/metrics/types.ts
@@ -0,0 +1,826 @@
+import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
+import {type FeedDescriptor} from '#/state/queries/post-feed'
+import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
+
+export type Events = {
+ // App events
+ init: {
+ initMs: number
+ }
+ 'experiment:viewed': {
+ experimentId: string
+ variationId: string
+ }
+
+ 'account:loggedIn': {
+ logContext:
+ | 'LoginForm'
+ | 'SwitchAccount'
+ | 'ChooseAccountForm'
+ | 'Settings'
+ | 'Notification'
+ withPassword: boolean
+ }
+ 'account:loggedOut': {
+ logContext:
+ | 'SwitchAccount'
+ | 'Settings'
+ | 'SignupQueued'
+ | 'Deactivated'
+ | 'Takendown'
+ | 'AgeAssuranceNoAccessScreen'
+ scope: 'current' | 'every'
+ }
+ 'notifications:openApp': {
+ reason: NotificationReason
+ causedBoot: boolean
+ }
+ 'notifications:request': {
+ context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
+ status: 'granted' | 'denied' | 'undetermined'
+ }
+ 'state:background': {
+ secondsActive: number
+ }
+ 'state:foreground': {}
+ 'router:navigate': {
+ from?: string
+ }
+ 'deepLink:referrerReceived': {
+ to: string
+ referrer: string
+ hostname: string
+ }
+
+ // Screen events
+ 'splash:signInPressed': {}
+ 'splash:createAccountPressed': {}
+ 'welcomeModal:signupClicked': {}
+ 'welcomeModal:exploreClicked': {}
+ 'welcomeModal:signinClicked': {}
+ 'welcomeModal:dismissed': {}
+ 'welcomeModal:presented': {}
+ 'signup:nextPressed': {
+ activeStep: number
+ phoneVerificationRequired?: boolean
+ }
+ 'signup:backPressed': {
+ activeStep: number
+ }
+ 'signup:captchaSuccess': {}
+ 'signup:captchaFailure': {}
+ 'signup:fieldError': {
+ field: string
+ errorCount: number
+ errorMessage: string
+ activeStep: number
+ }
+ 'signup:backgrounded': {
+ activeStep: number
+ backgroundCount: number
+ }
+ 'signup:handleTaken': {typeahead?: boolean}
+ 'signup:handleAvailable': {typeahead?: boolean}
+ 'signup:handleSuggestionSelected': {method: string}
+ 'signin:hostingProviderPressed': {
+ hostingProviderDidChange: boolean
+ }
+ 'signin:hostingProviderFailedResolution': {}
+ 'signin:success': {
+ failedAttemptsCount: number
+ isUsingCustomProvider: boolean
+ timeTakenSeconds: number
+ }
+ 'signin:backPressed': {
+ failedAttemptsCount: number
+ }
+ 'signin:forgotPasswordPressed': {}
+ 'signin:passwordReset': {}
+ 'signin:passwordResetSuccess': {}
+ 'signin:passwordResetFailure': {}
+ 'onboarding:interests:nextPressed': {
+ selectedInterests: string[]
+ selectedInterestsLength: number
+ }
+ 'onboarding:suggestedAccounts:tabPressed': {
+ tab: string
+ }
+ 'onboarding:suggestedAccounts:followAllPressed': {
+ tab: string
+ numAccounts: number
+ }
+ 'onboarding:suggestedAccounts:nextPressed': {
+ selectedAccountsLength: number
+ skipped: boolean
+ }
+ 'onboarding:followingFeed:nextPressed': {}
+ 'onboarding:algoFeeds:nextPressed': {
+ selectedPrimaryFeeds: string[]
+ selectedPrimaryFeedsLength: number
+ selectedSecondaryFeeds: string[]
+ selectedSecondaryFeedsLength: number
+ }
+ 'onboarding:topicalFeeds:nextPressed': {
+ selectedFeeds: string[]
+ selectedFeedsLength: number
+ }
+ 'onboarding:moderation:nextPressed': {}
+ 'onboarding:profile:nextPressed': {}
+ 'onboarding:finished:nextPressed': {
+ usedStarterPack: boolean
+ starterPackName?: string
+ starterPackCreator?: string
+ starterPackUri?: string
+ profilesFollowed: number
+ feedsPinned: number
+ }
+ 'onboarding:finished:avatarResult': {
+ avatarResult: 'default' | 'created' | 'uploaded'
+ }
+ 'onboarding:valueProp:stepOne:nextPressed': {}
+ 'onboarding:valueProp:stepTwo:nextPressed': {}
+ 'onboarding:valueProp:skipPressed': {}
+ 'home:feedDisplayed': {
+ feedUrl: string
+ feedType: string
+ index: number
+ }
+ 'feed:endReached': {
+ feedUrl: string
+ feedType: string
+ itemCount: number
+ }
+ 'feed:refresh': {
+ feedUrl: string
+ feedType: string
+ reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
+ }
+ 'feed:save': {
+ feedUrl: string
+ }
+ 'feed:unsave': {
+ feedUrl: string
+ }
+ 'feed:pin': {
+ feedUrl: string
+ }
+ 'feed:unpin': {
+ feedUrl: string
+ }
+ 'feed:like': {
+ feedUrl: string
+ }
+ 'feed:unlike': {
+ feedUrl: string
+ }
+ 'feed:share': {
+ feedUrl: string
+ }
+ 'feed:suggestion:seen': {
+ feedUrl: string
+ }
+ 'feed:suggestion:press': {
+ feedUrl: string
+ }
+ 'post:showMore': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:showLess': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'feed:clickthrough': {
+ feed: string
+ count: number
+ }
+ 'feed:engaged': {
+ feed: string
+ count: number
+ }
+ 'feed:seen': {
+ feed: string
+ count: number
+ }
+
+ 'feed:discover:emptyError': {
+ userDid: string
+ }
+
+ 'composer:gif:open': {}
+ 'composer:gif:select': {}
+ 'composerPrompt:press': {}
+ 'composerPrompt:camera:press': {}
+ 'composerPrompt:gallery:press': {}
+
+ 'composer:threadgate:open': {
+ nudged: boolean
+ }
+ 'composer:threadgate:save': {
+ replyOptions: string
+ quotesEnabled: boolean
+ persist: boolean
+ hasChanged: boolean
+ }
+
+ // Data events
+ 'account:create:begin': {}
+ 'account:create:success': {
+ signupDuration: number
+ fieldErrorsTotal: number
+ backgroundCount: number
+ }
+ 'post:create': {
+ imageCount: number
+ isReply: boolean
+ isPartOfThread: boolean
+ hasLink: boolean
+ hasQuote: boolean
+ langs: string
+ logContext: 'Composer'
+ }
+ 'thread:create': {
+ postCount: number
+ isReply: boolean
+ }
+ 'post:like': {
+ uri: string
+ authorDid: string
+ doesLikerFollowPoster: boolean | undefined
+ doesPosterFollowLiker: boolean | undefined
+ likerClout: number | undefined
+ postClout: number | undefined
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ }
+ 'post:repost': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ }
+ 'post:unlike': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ }
+ 'post:unrepost': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ }
+ 'post:mute': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:unmute': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:pin': {}
+ 'post:unpin': {}
+ 'post:bookmark': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:unbookmark': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:clickReply': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:clickQuotePost': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:clickthroughAuthor': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:clickthroughItem': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:clickthroughEmbed': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'post:view': {
+ uri: string
+ authorDid: string
+ logContext:
+ | 'FeedItem'
+ | 'PostThreadItem'
+ | 'Post'
+ | 'ImmersiveVideo'
+ | 'SearchResults'
+ | 'Bookmarks'
+ | 'Notifications'
+ | 'Hashtag'
+ | 'Topic'
+ | 'PostQuotes'
+ feedDescriptor?: string
+ position?: number
+ }
+ 'bookmarks:view': {}
+ 'bookmarks:post-clicked': {}
+ 'profile:follow': {
+ contextProfileDid?: string
+ didBecomeMutual: boolean | undefined
+ followeeClout: number | undefined
+ followeeDid: string
+ followerClout: number | undefined
+ position?: number
+ logContext:
+ | 'RecommendedFollowsItem'
+ | 'PostThreadItem'
+ | 'ProfileCard'
+ | 'ProfileHeader'
+ | 'ProfileHeaderSuggestedFollows'
+ | 'ProfileMenu'
+ | 'ProfileHoverCard'
+ | 'AvatarButton'
+ | 'StarterPackProfilesList'
+ | 'FeedInterstitial'
+ | 'PostOnboardingFindFollows'
+ | 'ImmersiveVideo'
+ | 'ExploreSuggestedAccounts'
+ | 'OnboardingSuggestedAccounts'
+ | 'FindContacts'
+ }
+ 'profile:followers:view': {
+ contextProfileDid: string
+ isOwnProfile: boolean
+ }
+ 'profile:followers:paginate': {
+ contextProfileDid: string
+ itemCount: number
+ page: number
+ }
+ 'profile:following:view': {
+ contextProfileDid: string
+ isOwnProfile: boolean
+ }
+ 'profile:following:paginate': {
+ contextProfileDid: string
+ itemCount: number
+ page: number
+ }
+ 'profileCard:seen': {
+ contextProfileDid?: string
+ profileDid: string
+ position?: number
+ }
+ 'suggestedUser:follow': {
+ logContext:
+ | 'Explore'
+ | 'InterstitialDiscover'
+ | 'InterstitialProfile'
+ | 'Profile'
+ | 'Onboarding'
+ location: 'Card' | 'Profile'
+ recId?: number
+ position: number
+ suggestedDid: string
+ category: string | null
+ }
+ 'suggestedUser:press': {
+ logContext:
+ | 'Explore'
+ | 'InterstitialDiscover'
+ | 'InterstitialProfile'
+ | 'Onboarding'
+ recId?: number
+ position: number
+ suggestedDid: string
+ category: string | null
+ }
+ 'suggestedUser:seen': {
+ logContext:
+ | 'Explore'
+ | 'InterstitialDiscover'
+ | 'InterstitialProfile'
+ | 'Profile'
+ | 'Onboarding'
+ | 'ProgressGuide'
+ recId?: number
+ position: number
+ suggestedDid: string
+ category: string | null
+ }
+ 'suggestedUser:seeMore': {
+ logContext:
+ | 'Explore'
+ | 'InterstitialDiscover'
+ | 'InterstitialProfile'
+ | 'Profile'
+ | 'Onboarding'
+ }
+ 'suggestedUser:dismiss': {
+ logContext: 'InterstitialDiscover' | 'InterstitialProfile'
+ recId?: number
+ position: number
+ suggestedDid: string
+ }
+ 'profile:unfollow': {
+ logContext:
+ | 'RecommendedFollowsItem'
+ | 'PostThreadItem'
+ | 'ProfileCard'
+ | 'ProfileHeader'
+ | 'ProfileHeaderSuggestedFollows'
+ | 'ProfileMenu'
+ | 'ProfileHoverCard'
+ | 'Chat'
+ | 'AvatarButton'
+ | 'StarterPackProfilesList'
+ | 'FeedInterstitial'
+ | 'PostOnboardingFindFollows'
+ | 'ImmersiveVideo'
+ | 'ExploreSuggestedAccounts'
+ | 'OnboardingSuggestedAccounts'
+ | 'FindContacts'
+ }
+ 'chat:create': {
+ logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+ }
+ 'chat:open': {
+ logContext:
+ | 'ProfileHeader'
+ | 'NewChatDialog'
+ | 'ChatsList'
+ | 'SendViaChatDialog'
+ }
+ 'starterPack:addUser': {
+ starterPack?: string
+ }
+ 'starterPack:removeUser': {
+ starterPack?: string
+ }
+ 'starterPack:share': {
+ starterPack: string
+ shareType: 'link' | 'qrcode'
+ qrShareType?: 'save' | 'copy' | 'share'
+ }
+ 'starterPack:followAll': {
+ logContext: 'StarterPackProfilesList' | 'Onboarding'
+ starterPack: string
+ count: number
+ }
+ 'starterPack:delete': {}
+ 'starterPack:create': {
+ setName: boolean
+ setDescription: boolean
+ profilesCount: number
+ feedsCount: number
+ }
+ 'starterPack:ctaPress': {
+ starterPack: string
+ }
+ 'starterPack:opened': {
+ starterPack: string
+ }
+ 'link:clicked': {
+ url: string
+ domain: string
+ }
+
+ 'feed:interstitial:feedCard:press': {}
+ 'desktopFeeds:feed:click': {
+ feedUri: string
+ feedDescriptor: string
+ }
+
+ 'profile:header:suggestedFollowsCard:press': {}
+ 'profile:addToStarterPack': {}
+
+ 'test:all:always': {}
+ 'test:all:sometimes': {}
+ 'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
+ 'test:all:boosted_by_gate2': {reason: 'base' | 'gate2'}
+ 'test:all:boosted_by_both': {reason: 'base' | 'gate1' | 'gate2'}
+ 'test:gate1:always': {}
+ 'test:gate1:sometimes': {}
+ 'test:gate2:always': {}
+ 'test:gate2:sometimes': {}
+
+ 'tmd:share': {}
+ 'tmd:download': {}
+ 'tmd:post': {}
+
+ 'trendingTopics:show': {
+ context: 'settings'
+ }
+ 'trendingTopics:hide': {
+ context: 'settings' | 'sidebar' | 'interstitial' | 'explore:trending'
+ }
+ 'trendingTopic:click': {
+ context: 'sidebar' | 'interstitial' | 'explore'
+ }
+ 'recommendedTopic:click': {
+ context: 'explore'
+ }
+ 'trendingVideos:show': {
+ context: 'settings'
+ }
+ 'trendingVideos:hide': {
+ context: 'settings' | 'interstitial:discover' | 'interstitial:explore'
+ }
+ 'videoCard:click': {
+ context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
+ }
+
+ 'explore:module:seen': {
+ module:
+ | 'trendingTopics'
+ | 'trendingVideos'
+ | 'suggestedAccounts'
+ | 'suggestedFeeds'
+ | 'suggestedStarterPacks'
+ | `feed:${FeedDescriptor}`
+ }
+ 'explore:module:searchButtonPress': {
+ module: 'suggestedAccounts' | 'suggestedFeeds'
+ }
+ 'explore:suggestedAccounts:tabPressed': {
+ tab: string
+ }
+
+ 'progressGuide:hide': {}
+ 'progressGuide:followDialog:open': {}
+
+ 'moderation:subscribedToLabeler': {}
+ 'moderation:unsubscribedFromLabeler': {}
+ 'moderation:changeLabelPreference': {
+ preference: string
+ }
+
+ 'moderation:subscribedToList': {
+ listType: 'mute' | 'block'
+ }
+ 'moderation:unsubscribedFromList': {
+ listType: 'mute' | 'block'
+ }
+
+ 'reportDialog:open': {
+ subjectType: string
+ }
+ 'reportDialog:close': {}
+ 'reportDialog:success': {
+ reason: string
+ labeler: string
+ details: boolean
+ }
+ 'reportDialog:failure': {}
+
+ translate: {
+ sourceLanguages: string[]
+ targetLanguage: string
+ textLength: number
+ }
+
+ 'verification:create': {}
+ 'verification:revoke': {}
+ 'verification:badge:click': {}
+ 'verification:learn-more': {
+ location:
+ | 'initialAnnouncementeNux'
+ | 'verificationsDialog'
+ | 'verifierDialog'
+ | 'verificationSettings'
+ }
+ 'verification:settings:hideBadges': {}
+ 'verification:settings:unHideBadges': {}
+
+ 'live:create': {duration: number}
+ 'live:edit': {}
+ 'live:remove': {}
+ 'live:card:open': {subject: string; from: 'post' | 'profile'}
+ 'live:card:watch': {subject: string}
+ 'live:card:openProfile': {subject: string}
+ 'live:view:profile': {subject: string}
+ 'live:view:post': {subject: string; feed?: string}
+
+ 'post:share': {
+ uri: string
+ authorDid: string
+ logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ feedDescriptor?: string
+ postContext: 'feed' | 'thread'
+ position?: number
+ }
+ 'share:press:copyLink': {}
+ 'share:press:nativeShare': {}
+ 'share:press:openDmSearch': {}
+ 'share:press:dmSelected': {}
+ 'share:press:recentDm': {}
+ 'share:press:embed': {}
+
+ 'thread:click:showOtherReplies': {}
+ 'thread:click:hideReplyForMe': {}
+ 'thread:click:hideReplyForEveryone': {}
+ 'thread:preferences:load': {
+ [key: string]: any
+ }
+ 'thread:preferences:update': {
+ [key: string]: any
+ }
+ 'thread:click:headerMenuOpen': {}
+ 'thread:click:editOwnThreadgate': {}
+ 'thread:click:viewSomeoneElsesThreadgate': {}
+ 'activitySubscription:enable': {
+ setting: 'posts' | 'posts_and_replies'
+ }
+ 'activitySubscription:disable': {}
+ 'activityPreference:changeChannels': {
+ name: string
+ push: boolean
+ list: boolean
+ }
+ 'activityPreference:changeFilter': {
+ name: string
+ value: string
+ }
+
+ 'ageAssurance:navigateToSettings': {}
+ 'ageAssurance:dismissFeedBanner': {}
+ 'ageAssurance:dismissSettingsNotice': {}
+ 'ageAssurance:initDialogOpen': {
+ hasInitiatedPreviously: boolean
+ }
+ 'ageAssurance:initDialogSubmit': {}
+ 'ageAssurance:api:begin': {
+ platform: string
+ countryCode: string
+ regionCode?: string
+ }
+ 'ageAssurance:initDialogError': {
+ code: string
+ }
+ 'ageAssurance:redirectDialogOpen': {}
+ 'ageAssurance:redirectDialogSuccess': {}
+ 'ageAssurance:redirectDialogFail': {}
+ 'ageAssurance:appealDialogOpen': {}
+ 'ageAssurance:appealDialogSubmit': {}
+ 'ageAssurance:noAccessScreen:shown': {
+ accountCreatedAt: string
+ isAARegion: boolean
+ hasDeclaredAge: boolean
+ canUpdateBirthday: boolean
+ }
+ 'ageAssurance:noAccessScreen:openBirthdateDialog': {}
+
+ /*
+ * Specifically for the `BlockedGeoOverlay`
+ */
+ 'blockedGeoOverlay:shown': {}
+
+ 'geo:debug': {}
+
+ /*
+ * Find Contacts stuff
+ */
+
+ // user presses the button on the new feature NUX
+ 'contacts:nux:ctaPressed': {}
+ // user presses the banner NUX
+ 'contacts:nux:bannerPressed': {}
+ // user dismisses the banner
+ 'contacts:nux:bannerDismissed': {}
+
+ // user lands on the contacts step
+ 'onboarding:contacts:presented': {}
+ // user pressed "Import Contacts" button to begin flow
+ 'onboarding:contacts:begin': {}
+ // skips the step entirely
+ 'onboarding:contacts:skipPressed': {}
+ // user shared their contacts
+ 'onboarding:contacts:contactsShared': {}
+ // user leaves the matches page
+ 'onboarding:contacts:nextPressed': {
+ matchCount: number
+ followCount: number
+ dismissedMatchCount: number
+ }
+
+ // user entered a number
+ 'contacts:phone:phoneEntered': {
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user entered the correct one-time-code
+ 'contacts:phone:phoneVerified': {
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user responded to the contacts permission prompt
+ 'contacts:permission:request': {
+ status: 'granted' | 'denied'
+ accessLevelIOS?: 'all' | 'limited' | 'none'
+ }
+ // contacts were successfully imported and matched
+ 'contacts:import:success': {
+ contactCount: number
+ matchCount: number
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // contacts import failed
+ 'contacts:import:failure': {
+ reason: 'noValidNumbers' | 'networkError' | 'unknown'
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user followed a single match
+ 'contacts:matches:follow': {
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user pressed "Follow All" on matches
+ 'contacts:matches:followAll': {
+ followCount: number
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user dismissed a match
+ 'contacts:matches:dismiss': {
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user pressed invite to send an SMS to a non-match
+ 'contacts:matches:invite': {
+ entryPoint: 'Onboarding' | 'Standalone'
+ }
+ // user opened the Find Contacts settings screen
+ 'contacts:settings:presented': {
+ hasPreviouslySynced: boolean
+ matchCount?: number
+ }
+ // user followed a single match from settings
+ 'contacts:settings:follow': {}
+ // user pressed "Follow All" from settings
+ 'contacts:settings:followAll': {
+ followCount: number
+ }
+ // user dismissed a match from settings
+ 'contacts:settings:dismiss': {}
+ // user re-entered the flow via the resync button
+ 'contacts:settings:resync': {
+ daysSinceLastSync: number
+ }
+ // user pressed the remove all data button
+ 'contacts:settings:removeData': {}
+
+ 'liveEvents:feedBanner:seen': {
+ feed: string
+ context: LiveEventFeedMetricContext
+ }
+ 'liveEvents:feedBanner:click': {
+ feed: string
+ context: LiveEventFeedMetricContext
+ }
+ 'liveEvents:feedBanner:hide': {
+ feed: string
+ context: LiveEventFeedMetricContext
+ }
+ 'liveEvents:feedBanner:unhide': {
+ feed: string
+ context: LiveEventFeedMetricContext
+ }
+ 'liveEvents:hideAllFeedBanners': {
+ context: LiveEventFeedMetricContext
+ }
+ 'liveEvents:unhideAllFeedBanners': {
+ context: LiveEventFeedMetricContext
+ }
+}
diff --git a/src/analytics/misc/referrer.ts b/src/analytics/misc/referrer.ts
new file mode 100644
index 0000000000..ab98ad7ac4
--- /dev/null
+++ b/src/analytics/misc/referrer.ts
@@ -0,0 +1,12 @@
+import * as env from '#/env'
+
+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') ?? '')
+}
+
+export const src = refSrc
+export const url = refUrl
diff --git a/src/analytics/types.ts b/src/analytics/types.ts
new file mode 100644
index 0000000000..87923f04a6
--- /dev/null
+++ b/src/analytics/types.ts
@@ -0,0 +1,34 @@
+import {type Geolocation} from '#/geolocation'
+
+export type BaseMetadata = {
+ deviceId: string
+ sessionId: string
+ platform: string
+ appVersion: string
+ bundleIdentifier: string
+ bundleDate: number
+ referrerSrc: string
+ referrerUrl: string
+}
+
+export type GeolocationMetadata = Geolocation
+
+export type SessionMetadata = {
+ did: string
+ isBskyPds: boolean
+}
+
+export type PreferencesMetadata = {
+ appLanguage: string
+ contentLanguages: string[]
+}
+
+export type MergeableMetadata = {
+ session?: SessionMetadata
+ preferences?: PreferencesMetadata
+}
+
+export type Metadata = {
+ base: BaseMetadata
+ geolocation: GeolocationMetadata
+} & MergeableMetadata
diff --git a/src/analytics/utils.ts b/src/analytics/utils.ts
new file mode 100644
index 0000000000..5a0099bfc1
--- /dev/null
+++ b/src/analytics/utils.ts
@@ -0,0 +1,26 @@
+import {useMemo} from 'react'
+
+import {BSKY_SERVICE} from '#/lib/constants'
+import {type SessionAccount} from '#/state/session'
+import {type MergeableMetadata, type SessionMetadata} from '#/analytics/types'
+
+export function meta(metadata: MergeableMetadata) {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const m = useMemo(() => metadata, [metadata])
+ // @ts-ignore
+ m.__meta = true
+ return m
+}
+
+export function accountToSessionMetadata(
+ account: SessionAccount | undefined,
+): SessionMetadata | undefined {
+ if (!account) {
+ return
+ } else {
+ return {
+ did: account.did,
+ isBskyPds: account.service.startsWith(BSKY_SERVICE),
+ }
+ }
+}
diff --git a/src/logger/metadata/sessionId.web.ts b/src/logger/metadata/sessionId.web.ts
index e45096792e..e86a1a8760 100644
--- a/src/logger/metadata/sessionId.web.ts
+++ b/src/logger/metadata/sessionId.web.ts
@@ -1,7 +1,6 @@
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
-import {updateBaseMetadata} from '#/logger/metadata'
const TTL = 30 * 60 * 1e3 // 30 min on web
const SESSION_ID_KEY = 'bsky_session_id'
@@ -18,7 +17,7 @@ let sessionId = (() => {
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
const id = existing && !expired(lastEvent) ? existing : uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, id)
- updateBaseMetadata({sessionId: id})
+ // updateBaseMetadata({sessionId: id})
return id
})()
@@ -29,7 +28,7 @@ onAppStateChange(state => {
if (expired(lastEvent)) {
sessionId = uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
- updateBaseMetadata({sessionId})
+ // updateBaseMetadata({sessionId})
}
} else {
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx
index 5d4336814c..e69258d13b 100644
--- a/src/state/preferences/languages.tsx
+++ b/src/state/preferences/languages.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import {type AppLanguage} from '#/locale/languages'
import * as persisted from '#/state/persisted'
+import {AnalyticsContext, utils} from '#/analytics'
type SetStateCb = (
s: persisted.Schema['languagePrefs'],
@@ -124,7 +125,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return (
- {children}
+
+
+ {children}
+
+
)
}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 3b4e723be8..609eb6d895 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,10 +1,10 @@
import React from 'react'
import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
-import {setUserMetadata} from '#/logger/metadata'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {AnalyticsContext, useAnalytics, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {emitSessionDropped} from '../events'
import {
@@ -19,7 +19,6 @@ import {type Action, getInitialState, reducer, type State} from './reducer'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
-import {logger} from '#/logger'
import {
type SessionApiContext,
type SessionStateContext,
@@ -93,6 +92,7 @@ class SessionStore {
}
export function Provider({children}: React.PropsWithChildren<{}>) {
+ const ax = useAnalytics()
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)
@@ -111,9 +111,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
accountDid,
sessionEvent,
})
- if (!refreshedAccount) {
- setUserMetadata(null)
- }
},
[store],
)
@@ -122,7 +119,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
async (params, metrics) => {
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask()
- logger.metric('account:create:begin', {}, {statsig: true})
+ ax.metric('account:create:begin', {})
const {agent, account} = await createAgentAndCreateAccount(
params,
onAgentSessionChange,
@@ -136,10 +133,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: agent,
newAccount: account,
})
- logger.metric('account:create:success', metrics, {statsig: true})
+ ax.metric('account:create:success', metrics, {
+ session: utils.accountToSessionMetadata(account),
+ })
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
},
- [store, onAgentSessionChange, cancelPendingTask],
+ [ax, store, onAgentSessionChange, cancelPendingTask],
)
const login = React.useCallback(
@@ -159,14 +158,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: agent,
newAccount: account,
})
- logger.metric(
+ ax.metric(
'account:loggedIn',
{logContext, withPassword: true},
- {statsig: true},
+ {session: utils.accountToSessionMetadata(account)},
)
addSessionDebugLog({type: 'method:end', method: 'login', account})
},
- [store, onAgentSessionChange, cancelPendingTask],
+ [ax, store, onAgentSessionChange, cancelPendingTask],
)
const logoutCurrentAccount = React.useCallback<
@@ -179,20 +178,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
store.dispatch({
type: 'logged-out-current-account',
})
- logger.metric(
+ ax.metric(
'account:loggedOut',
{logContext, scope: 'current'},
- {statsig: true},
+ {
+ session: utils.accountToSessionMetadata(
+ prevState.accounts.find(
+ a => a.did === prevState.currentAgentState.did,
+ ),
+ ),
+ },
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
- setUserMetadata(null)
if (prevState.currentAgentState.did) {
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
}
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
},
- [store, cancelPendingTask, onboardingDispatch],
+ [ax, store, cancelPendingTask, onboardingDispatch],
)
const logoutEveryAccount = React.useCallback<
@@ -201,16 +205,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
+ const prevState = store.getState()
store.dispatch({
type: 'logged-out-every-account',
})
- logger.metric(
+ ax.metric(
'account:loggedOut',
{logContext, scope: 'every'},
- {statsig: true},
+ {
+ session: utils.accountToSessionMetadata(
+ prevState.accounts.find(
+ a => a.did === prevState.currentAgentState.did,
+ ),
+ ),
+ },
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
- setUserMetadata(null)
clearAgeAssuranceData()
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
@@ -364,7 +374,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return (
- {children}
+
+
+ {children}
+
+
)