feat(tracking): integrate PostHog and Firebase tracking, add client and session ID management

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-30 17:11:15 +08:00
parent 41482bccb3
commit 616e794d4c
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
16 changed files with 246 additions and 133 deletions

View File

@ -1,9 +1,11 @@
import { env } from "@follow/shared/env.desktop"
import type { AuthSession } from "@follow/shared/hono"
import { setFirebaseTracker, tracker } from "@follow/tracker"
import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker"
import posthog from "posthog-js"
import { QUERY_PERSIST_KEY } from "~/constants/app"
import { ga4 } from "./ga4"
import { ga4 } from "../lib/ga4"
export const initAnalytics = async () => {
tracker.manager.appendUserProperties({
@ -15,6 +17,14 @@ export const initAnalytics = async () => {
setFirebaseTracker(ga4)
setPostHogTracker(
posthog.init(env.VITE_POSTHOG_KEY, {
api_host: env.VITE_POSTHOG_HOST,
person_profiles: "identified_only",
defaults: "2025-05-24",
}),
)
let session: AuthSession | undefined
try {
const queryData = JSON.parse(window.localStorage.getItem(QUERY_PERSIST_KEY) ?? "{}")

View File

@ -12,6 +12,8 @@ import { setLoginModalShow } from "~/atoms/user"
import { NeedActivationToast } from "~/modules/activation/NeedActivationToast"
import { DebugRegistry } from "~/modules/debug/registry"
import { getClientId, getSessionId } from "./client-session"
export const apiFetch = ofetch.create({
baseURL: env.VITE_API_URL,
credentials: "include",
@ -19,11 +21,13 @@ export const apiFetch = ofetch.create({
onRequest: ({ options }) => {
const header = new Headers(options.headers)
header.set("x-app-version", PKG.version)
header.set("X-App-Version", PKG.version)
if (DEV) {
header.set("X-App-Dev", "1")
}
header.set("X-App-Name", "Folo Web")
header.set("X-Client-Id", getClientId())
header.set("X-Session-Id", getSessionId())
options.headers = header
},
onResponse() {
@ -87,6 +91,8 @@ export const apiClient = hc<AppType>(env.VITE_API_URL, {
return {
"X-App-Version": PKG.version,
"X-App-Name": "Folo Web",
"X-Client-Id": getClientId(),
"X-Session-Id": getSessionId(),
}
},
})

View File

@ -0,0 +1,33 @@
import { getStorageNS } from "@follow/utils/ns"
import { nanoid } from "nanoid"
const CLIENT_ID_KEY = getStorageNS("client_id")
const SESSION_ID_KEY = getStorageNS("session_id")
export const getClientId = (): string => {
const clientId = localStorage.getItem(CLIENT_ID_KEY)
if (!clientId) {
const newClientId = nanoid()
localStorage.setItem(CLIENT_ID_KEY, newClientId)
return newClientId
}
return clientId
}
export const getSessionId = (): string => {
const sessionId = sessionStorage.getItem(SESSION_ID_KEY)
if (!sessionId) {
const newSessionId = nanoid()
sessionStorage.setItem(SESSION_ID_KEY, newSessionId)
return newSessionId
}
return sessionId
}
export const clearSessionId = (): void => {
sessionStorage.removeItem(SESSION_ID_KEY)
}
export const clearClientId = (): void => {
localStorage.removeItem(CLIENT_ID_KEY)
}

View File

@ -1,6 +1,5 @@
import { v4 as uuidv4 } from "uuid"
import { apiClient } from "~/lib/api-fetch"
import { getClientId, getSessionId } from "~/lib/client-session"
class Analytics4 {
private clientID: string
@ -8,7 +7,7 @@ class Analytics4 {
private userID: string | null = null
private userProperties: Record<string, { value: unknown }> | null = null
constructor(clientID: string = uuidv4(), sessionID = uuidv4()) {
constructor(clientID: string = getClientId(), sessionID = getSessionId()) {
this.clientID = clientID
this.sessionID = sessionID
}

View File

@ -8,8 +8,8 @@ export const DEFAULT_VALUES = {
RECAPTCHA_V3_SITE_KEY: "6LdG-asqAAAAAEXr96565MKbRvxGEv31XEykRSHV",
HCAPTCHA_SITE_KEY: "f5324fa4-91a0-4c72-840e-001220a6ebda",
POSTHOG_KEY: "phc_YACSR3QpnlRHkbJx7t86aVp3LDAwzkgN82l7okHZEmR",
POSTHOG_HOST: "https://posthog.follow.is",
POSTHOG_KEY: "phc_EZGEvBt830JgBHTiwpHqJAEbWnbv63m5UpreojwEWNL",
POSTHOG_HOST: "https://us.posthog.com",
},
DEV: {
API_URL: "https://api.dev.follow.is",
@ -23,8 +23,8 @@ export const DEFAULT_VALUES = {
OPENPANEL_CLIENT_ID: "4382168f-b8d2-40c1-9a26-133a312d072b",
OPENPANEL_API_URL: "https://openpanel.follow.is/api",
POSTHOG_KEY: "phc_YACSR3QpnlRHkbJx7t86aVp3LDAwzkgN82l7okHZEmR",
POSTHOG_HOST: "https://posthog.follow.is",
POSTHOG_KEY: "phc_EZGEvBt830JgBHTiwpHqJAEbWnbv63m5UpreojwEWNL",
POSTHOG_HOST: "https://us.posthog.com",
},
LOCAL: {
API_URL: "http://localhost:3000",

View File

@ -1,6 +1,6 @@
import type { FirebaseAnalyticsTypes } from "@react-native-firebase/analytics"
import { TrackerMapper } from "../points"
import { TrackerMapper } from "../enums"
import type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export interface FirebaseAdapterConfig {

View File

@ -2,3 +2,4 @@ export type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export { FirebaseAdapter, type FirebaseAdapterConfig } from "./firebase"
export { OpenPanelAdapter, type OpenPanelAdapterConfig } from "./openpanel"
export { PostHogAdapter, type PostHogAdapterConfig } from "./posthog"
export { ProxyAdapter } from "./proxy"

View File

@ -1,6 +1,6 @@
import { TrackerMapper } from "../enums"
import type { OpenPanel } from "../op"
import type { Optional } from "../points"
import { TrackerMapper } from "../points"
import type { Optional } from "../types"
import type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export interface OpenPanelAdapterConfig {

View File

@ -0,0 +1,46 @@
import type { TrackerAdapter, TrackPayload } from "./base"
export class ProxyAdapter implements TrackerAdapter {
private enabled = false
constructor(
private config: {
enabled: boolean
sender: (eventName: string, properties: Record<string, unknown>) => Promise<void>
},
) {
this.enabled = config.enabled
}
private globalProperties: Record<string, unknown> = {}
initialize(): void {}
clear(): void {
this.globalProperties = {}
}
identify(): void {}
setUserProperties(properties: Record<string, unknown>): void {
this.globalProperties = {
...this.globalProperties,
...properties,
}
}
async track(payload: TrackPayload): Promise<void> {
if (!this.isEnabled()) return
return this.config.sender(payload.eventName, {
...this.globalProperties,
...payload.properties,
})
}
isEnabled(): boolean {
return this.enabled
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
getName(): string {
return "proxy"
}
}

View File

@ -0,0 +1,31 @@
export enum TrackerMapper {
Identify = 1000,
UserLogin = 1001,
UiRenderInit = 1002,
AppInit = 1003,
// Biz
NavigateEntry = 2000,
BoostSent = 2001,
Integration = 2002,
DailyReportModal = 2003,
SwitchToMasonry = 2004,
WideMode = 2005,
EntryContentHeaderImageGalleryClick = 2006,
SearchOpen = 2007,
QuickAddFeed = 2008,
PlayerOpenDuration = 2009,
UpdateRestart = 2010,
TipModalOpened = 2011,
FeedClaimed = 2012,
DailyRewardClaimed = 2013,
TipSent = 2014,
SubscribeModalOpened = 2015,
// https://docs.google.com/spreadsheets/d/1XlUxTxiXWIQDHFYa2eoPBeuosR1t2h8VFIjXEOqmjhY/edit?gid=0#gid=0
Register = 3000,
OnBoarding = 3001,
Subscribe = 3002,
EntryRead = 3003,
EntryAction = 3004,
ViewAction = 3005,
}

View File

@ -1,9 +1,11 @@
import { improvedTrackManager, TrackerPoints } from "./points"
import { improvedTrackManager } from "./track-manager"
import { TrackerPoints } from "./tracker-points"
export const setOpenPanelTracker =
improvedTrackManager.setOpenPanelTracker.bind(improvedTrackManager)
export const setFirebaseTracker = improvedTrackManager.setFirebaseTracker.bind(improvedTrackManager)
export const setPostHogTracker = improvedTrackManager.setPostHogTracker.bind(improvedTrackManager)
export const setProxyTracker = improvedTrackManager.setProxyTracker.bind(improvedTrackManager)
export const tracker = new TrackerPoints()
@ -15,8 +17,10 @@ export {
type OpenPanelAdapterConfig,
PostHogAdapter,
type PostHogAdapterConfig,
ProxyAdapter,
type TrackerAdapter,
type TrackPayload,
} from "./adapters"
export { TrackerManager, type TrackerManagerConfig } from "./manager"
export { improvedTrackManager, TrackerMapper, type TrackerPoints } from "./points"
export { improvedTrackManager, trackManager } from "./track-manager"
export { type AllTrackers, TrackerPoints } from "./tracker-points"

View File

@ -1,6 +1,6 @@
import type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./adapters"
import type { TrackerMapper } from "./points"
import { CodeToTrackerName } from "./points"
import type { TrackerMapper } from "./enums"
import { CodeToTrackerName } from "./utils"
export interface TrackerManagerConfig {
enableBatchProcessing?: boolean

View File

@ -0,0 +1,68 @@
import type { FirebaseAnalyticsTypes } from "@react-native-firebase/analytics"
import type { PostHog } from "posthog-js"
import { FirebaseAdapter, OpenPanelAdapter, PostHogAdapter } from "./adapters"
import { ProxyAdapter } from "./adapters/proxy"
import type { TrackerMapper } from "./enums"
import { TrackerManager } from "./manager"
import type { OpenPanel } from "./op"
import type { Optional, Tracker } from "./types"
class TrackManager extends TrackerManager {
private trackFns: Tracker[] = []
constructor() {
super({
enableBatchProcessing: false,
enableErrorRetry: true,
maxRetries: 2,
})
}
setTrackFn(fn: Tracker) {
this.trackFns.push(fn)
return () => {
this.trackFns = this.trackFns.filter((t) => t !== fn)
}
}
getTrackFn(): Tracker {
if (this.trackFns.length === 0 && this.getEnabledAdapters().length === 0) {
console.error("[Tracker warn]: Track function not set")
}
return (code, properties) => {
const legacyPromises = this.trackFns.map((fn) => fn(code, properties))
const modernPromise = this.track(code as TrackerMapper, properties)
return Promise.all([...legacyPromises, modernPromise])
}
}
setOpenPanelTracker(op: Optional<OpenPanel, "setHeaders">) {
const adapter = new OpenPanelAdapter({ instance: op })
this.addAdapter(adapter)
}
setFirebaseTracker(
tracker: Pick<FirebaseAnalyticsTypes.Module, "logEvent" | "setUserId" | "setUserProperties">,
) {
const adapter = new FirebaseAdapter({ instance: tracker })
this.addAdapter(adapter)
}
setPostHogTracker(posthog: PostHog) {
const adapter = new PostHogAdapter({ instance: posthog })
this.addAdapter(adapter)
}
setProxyTracker(config: {
enabled: boolean
sender: (eventName: string, properties: Record<string, unknown>) => Promise<void>
}) {
const adapter = new ProxyAdapter({ enabled: config.enabled, sender: config.sender })
this.addAdapter(adapter)
}
}
export const trackManager = new TrackManager()
export const improvedTrackManager = trackManager // Alias for backward compatibility

View File

@ -1,115 +1,6 @@
import type { FirebaseAnalyticsTypes } from "@react-native-firebase/analytics"
import type { PostHog } from "posthog-js"
import { FirebaseAdapter, OpenPanelAdapter, PostHogAdapter } from "./adapters"
import { TrackerManager } from "./manager"
import type { OpenPanel } from "./op"
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
type IdentifyPayload = {
id: string
name?: string | null
email?: string | null
image?: string | null
handle?: string | null
}
type Tracker = (code: number, properties?: Record<string, unknown>) => Promise<any>
class TrackManager extends TrackerManager {
private trackFns: Tracker[] = []
constructor() {
super({
enableBatchProcessing: false,
enableErrorRetry: true,
maxRetries: 2,
})
}
setTrackFn(fn: Tracker) {
this.trackFns.push(fn)
return () => {
this.trackFns = this.trackFns.filter((t) => t !== fn)
}
}
getTrackFn(): Tracker {
if (this.trackFns.length === 0 && this.getEnabledAdapters().length === 0) {
console.error("[Tracker warn]: Track function not set")
}
return (code, properties) => {
const legacyPromises = this.trackFns.map((fn) => fn(code, properties))
const modernPromise = this.track(code as TrackerMapper, properties)
return Promise.all([...legacyPromises, modernPromise])
}
}
setOpenPanelTracker(op: Optional<OpenPanel, "setHeaders">) {
const adapter = new OpenPanelAdapter({ instance: op })
this.addAdapter(adapter)
}
setFirebaseTracker(
tracker: Pick<FirebaseAnalyticsTypes.Module, "logEvent" | "setUserId" | "setUserProperties">,
) {
const adapter = new FirebaseAdapter({ instance: tracker })
this.addAdapter(adapter)
}
setPostHogTracker(posthog: PostHog) {
const adapter = new PostHogAdapter({ instance: posthog })
this.addAdapter(adapter)
}
}
export const trackManager = new TrackManager()
export const improvedTrackManager = trackManager // Alias for backward compatibility
export enum TrackerMapper {
Identify = 1000,
UserLogin = 1001,
UiRenderInit = 1002,
AppInit = 1003,
// Biz
NavigateEntry = 2000,
BoostSent = 2001,
Integration = 2002,
DailyReportModal = 2003,
SwitchToMasonry = 2004,
WideMode = 2005,
EntryContentHeaderImageGalleryClick = 2006,
SearchOpen = 2007,
QuickAddFeed = 2008,
PlayerOpenDuration = 2009,
UpdateRestart = 2010,
TipModalOpened = 2011,
FeedClaimed = 2012,
DailyRewardClaimed = 2013,
TipSent = 2014,
SubscribeModalOpened = 2015,
// https://docs.google.com/spreadsheets/d/1XlUxTxiXWIQDHFYa2eoPBeuosR1t2h8VFIjXEOqmjhY/edit?gid=0#gid=0
Register = 3000,
OnBoarding = 3001,
Subscribe = 3002,
EntryRead = 3003,
EntryAction = 3004,
ViewAction = 3005,
}
export const CodeToTrackerName = (code: number) => {
const map = Object.fromEntries(
Object.entries(TrackerMapper).map(([key, value]) => [value, key]),
) as Record<number, string>
const name = map[code]
if (name) {
return snakeCase(name)
} else {
throw new Error(`Tracker name not found for code ${code}`)
}
}
import { TrackerMapper } from "./enums"
import { trackManager } from "./track-manager"
import type { IdentifyPayload } from "./types"
export class TrackerPoints {
// App
@ -244,8 +135,4 @@ export class TrackerPoints {
}
}
const snakeCase = (string: string) => {
return string.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/^_/, "")
}
export type AllTrackers = keyof TrackerPoints

View File

@ -0,0 +1,11 @@
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
export type IdentifyPayload = {
id: string
name?: string | null
email?: string | null
image?: string | null
handle?: string | null
}
export type Tracker = (code: number, properties?: Record<string, unknown>) => Promise<any>

View File

@ -0,0 +1,17 @@
import { TrackerMapper } from "./enums"
export const CodeToTrackerName = (code: number) => {
const map = Object.fromEntries(
Object.entries(TrackerMapper).map(([key, value]) => [value, key]),
) as Record<number, string>
const name = map[code]
if (name) {
return snakeCase(name)
} else {
throw new Error(`Tracker name not found for code ${code}`)
}
}
const snakeCase = (string: string) => {
return string.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/^_/, "")
}