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} + + )