diff --git a/src/App.native.tsx b/src/App.native.tsx index d0ea72e685..9b895eeabf 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -14,7 +14,7 @@ import {RootStoreModel, setupState, RootStoreProvider} from './state' import {MobileShell} from './view/shell/mobile' import {s} from './view/lib/styles' import notifee, {EventType} from '@notifee/react-native' -import {segmentClient} from './lib/segmentClient' +import * as analytics from './lib/analytics' import * as Toast from './view/com/util/Toast' const App = observer(() => { @@ -26,9 +26,10 @@ const App = observer(() => { // init useEffect(() => { view.setup() - setSegment(segmentClient) + setSegment(analytics.segmentClient) setupState().then(store => { setRootStore(store) + analytics.init(store) SplashScreen.hide() Linking.getInitialURL().then((url: string | null) => { if (url) { diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000000..65e9507185 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,68 @@ +import {AppState, AppStateStatus} from 'react-native' +import {createClient} from '@segment/analytics-react-native' +import {RootStoreModel, AppInfo} from '../state/models/root-store' +// import {createLogger} from './logger' + +export const segmentClient = createClient({ + writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', + trackAppLifecycleEvents: false, + // Uncomment to debug: + // logger: createLogger(), +}) + +export function init(store: RootStoreModel) { + // NOTE + // this method is a copy of segment's own lifecycle event tracking + // we handle it manually to ensure that it never fires while the app is backgrounded + // -prf + segmentClient.onContextLoaded(() => { + if (AppState.currentState !== 'active') { + store.log.debug('Prevented a metrics ping while the app was backgrounded') + return + } + const context = segmentClient.context.get() + if (typeof context?.app === 'undefined') { + store.log.debug('Aborted metrics ping due to unavailable context') + return + } + + const oldAppInfo = store.appInfo + const newAppInfo = context.app as AppInfo + store.setAppInfo(newAppInfo) + store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) + + if (typeof oldAppInfo === 'undefined') { + segmentClient.track('Application Installed', { + version: newAppInfo.version, + build: newAppInfo.build, + }) + } else if (newAppInfo.version !== oldAppInfo.version) { + segmentClient.track('Application Updated', { + version: newAppInfo.version, + build: newAppInfo.build, + previous_version: oldAppInfo.version, + previous_build: oldAppInfo.build, + }) + } + segmentClient.track('Application Opened', { + from_background: false, + version: newAppInfo.version, + build: newAppInfo.build, + }) + }) + + let lastState: AppStateStatus = AppState.currentState + AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active' && lastState !== 'active') { + const context = segmentClient.context.get() + segmentClient.track('Application Opened', { + from_background: true, + version: context?.app?.version, + build: context?.app?.build, + }) + } else if (state !== 'active' && lastState === 'active') { + segmentClient.track('Application Backgrounded') + } + lastState = state + }) +} diff --git a/src/lib/segmentClient.ts b/src/lib/segmentClient.ts deleted file mode 100644 index 4286f74ad1..0000000000 --- a/src/lib/segmentClient.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {createClient} from '@segment/analytics-react-native' -// import {createLogger} from './logger' - -export const segmentClient = createClient({ - writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', - trackAppLifecycleEvents: true, - // Uncomment to debug: - // logger: createLogger(), -}) diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts index e2610a2f5c..cbb33fe098 100644 --- a/src/state/models/navigation.ts +++ b/src/state/models/navigation.ts @@ -1,7 +1,7 @@ import {RootStoreModel} from './root-store' import {makeAutoObservable} from 'mobx' import {TABS_ENABLED} from '../../build-flags' -import {segmentClient} from '../../lib/segmentClient' +import {segmentClient} from '../../lib/analytics' let __id = 0 function genId() { diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index fe7dac8f7c..8806184937 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -7,6 +7,7 @@ import {AtpAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' import BackgroundFetch from 'react-native-background-fetch' +import {z} from 'zod' import {isObj, hasProp} from '../lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' @@ -17,8 +18,17 @@ import {LinkMetasViewModel} from './link-metas-view' import {MeModel} from './me' import {OnboardModel} from './onboard' +export const appInfo = z.object({ + build: z.string(), + name: z.string(), + namespace: z.string(), + version: z.string(), +}) +export type AppInfo = z.infer + export class RootStoreModel { agent: AtpAgent + appInfo?: AppInfo log = new LogModel() session = new SessionModel(this) nav = new NavigationModel(this) @@ -42,8 +52,13 @@ export class RootStoreModel { return this.agent.api } + setAppInfo(info: AppInfo) { + this.appInfo = info + } + serialize(): unknown { return { + appInfo: this.appInfo, log: this.log.serialize(), session: this.session.serialize(), me: this.me.serialize(), @@ -55,6 +70,12 @@ export class RootStoreModel { hydrate(v: unknown) { if (isObj(v)) { + if (hasProp(v, 'appInfo')) { + const appInfoParsed = appInfo.safeParse(v.appInfo) + if (appInfoParsed.success) { + this.setAppInfo(appInfoParsed.data) + } + } if (hasProp(v, 'log')) { this.log.hydrate(v.log) }