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:
parent
41482bccb3
commit
616e794d4c
|
|
@ -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) ?? "{}")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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(/^_/, "")
|
||||
}
|
||||
Loading…
Reference in New Issue