diff --git a/apps/desktop/layer/renderer/src/initialize/analytics.ts b/apps/desktop/layer/renderer/src/initialize/analytics.ts index b243e77a5..00284e14e 100644 --- a/apps/desktop/layer/renderer/src/initialize/analytics.ts +++ b/apps/desktop/layer/renderer/src/initialize/analytics.ts @@ -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) ?? "{}") diff --git a/apps/desktop/layer/renderer/src/lib/api-fetch.ts b/apps/desktop/layer/renderer/src/lib/api-fetch.ts index 30a6f95b0..4334bb7df 100644 --- a/apps/desktop/layer/renderer/src/lib/api-fetch.ts +++ b/apps/desktop/layer/renderer/src/lib/api-fetch.ts @@ -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(env.VITE_API_URL, { return { "X-App-Version": PKG.version, "X-App-Name": "Folo Web", + "X-Client-Id": getClientId(), + "X-Session-Id": getSessionId(), } }, }) diff --git a/apps/desktop/layer/renderer/src/lib/client-session.ts b/apps/desktop/layer/renderer/src/lib/client-session.ts new file mode 100644 index 000000000..b2da93cf1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/client-session.ts @@ -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) +} diff --git a/apps/desktop/layer/renderer/src/initialize/ga4.ts b/apps/desktop/layer/renderer/src/lib/ga4.ts similarity index 89% rename from apps/desktop/layer/renderer/src/initialize/ga4.ts rename to apps/desktop/layer/renderer/src/lib/ga4.ts index dc227f515..943f10ed6 100644 --- a/apps/desktop/layer/renderer/src/initialize/ga4.ts +++ b/apps/desktop/layer/renderer/src/lib/ga4.ts @@ -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 | null = null - constructor(clientID: string = uuidv4(), sessionID = uuidv4()) { + constructor(clientID: string = getClientId(), sessionID = getSessionId()) { this.clientID = clientID this.sessionID = sessionID } diff --git a/packages/internal/shared/src/env.common.ts b/packages/internal/shared/src/env.common.ts index 6355b575e..35ea24a88 100644 --- a/packages/internal/shared/src/env.common.ts +++ b/packages/internal/shared/src/env.common.ts @@ -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", diff --git a/packages/internal/tracker/src/adapters/firebase.ts b/packages/internal/tracker/src/adapters/firebase.ts index 4a10464dc..d5733e8b8 100644 --- a/packages/internal/tracker/src/adapters/firebase.ts +++ b/packages/internal/tracker/src/adapters/firebase.ts @@ -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 { diff --git a/packages/internal/tracker/src/adapters/index.ts b/packages/internal/tracker/src/adapters/index.ts index 840bf6c52..578f50555 100644 --- a/packages/internal/tracker/src/adapters/index.ts +++ b/packages/internal/tracker/src/adapters/index.ts @@ -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" diff --git a/packages/internal/tracker/src/adapters/openpanel.ts b/packages/internal/tracker/src/adapters/openpanel.ts index f36351f7c..16c4a5160 100644 --- a/packages/internal/tracker/src/adapters/openpanel.ts +++ b/packages/internal/tracker/src/adapters/openpanel.ts @@ -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 { diff --git a/packages/internal/tracker/src/adapters/proxy.ts b/packages/internal/tracker/src/adapters/proxy.ts new file mode 100644 index 000000000..922149f77 --- /dev/null +++ b/packages/internal/tracker/src/adapters/proxy.ts @@ -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) => Promise + }, + ) { + this.enabled = config.enabled + } + + private globalProperties: Record = {} + + initialize(): void {} + clear(): void { + this.globalProperties = {} + } + identify(): void {} + setUserProperties(properties: Record): void { + this.globalProperties = { + ...this.globalProperties, + ...properties, + } + } + + async track(payload: TrackPayload): Promise { + 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" + } +} diff --git a/packages/internal/tracker/src/enums.ts b/packages/internal/tracker/src/enums.ts new file mode 100644 index 000000000..badbd8e6c --- /dev/null +++ b/packages/internal/tracker/src/enums.ts @@ -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, +} diff --git a/packages/internal/tracker/src/index.ts b/packages/internal/tracker/src/index.ts index 85c3bbe89..d74c04b3f 100644 --- a/packages/internal/tracker/src/index.ts +++ b/packages/internal/tracker/src/index.ts @@ -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" diff --git a/packages/internal/tracker/src/manager.ts b/packages/internal/tracker/src/manager.ts index eab1c9cbd..bf0bed15e 100644 --- a/packages/internal/tracker/src/manager.ts +++ b/packages/internal/tracker/src/manager.ts @@ -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 diff --git a/packages/internal/tracker/src/track-manager.ts b/packages/internal/tracker/src/track-manager.ts new file mode 100644 index 000000000..69df178f5 --- /dev/null +++ b/packages/internal/tracker/src/track-manager.ts @@ -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) { + const adapter = new OpenPanelAdapter({ instance: op }) + this.addAdapter(adapter) + } + + setFirebaseTracker( + tracker: Pick, + ) { + 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) => Promise + }) { + 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 diff --git a/packages/internal/tracker/src/points.ts b/packages/internal/tracker/src/tracker-points.ts similarity index 51% rename from packages/internal/tracker/src/points.ts rename to packages/internal/tracker/src/tracker-points.ts index 2f0a4867f..b5c7c65c3 100644 --- a/packages/internal/tracker/src/points.ts +++ b/packages/internal/tracker/src/tracker-points.ts @@ -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 = Pick, K> & Omit - -type IdentifyPayload = { - id: string - name?: string | null - email?: string | null - image?: string | null - handle?: string | null -} - -type Tracker = (code: number, properties?: Record) => Promise -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) { - const adapter = new OpenPanelAdapter({ instance: op }) - this.addAdapter(adapter) - } - - setFirebaseTracker( - tracker: Pick, - ) { - 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 - 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 diff --git a/packages/internal/tracker/src/types.ts b/packages/internal/tracker/src/types.ts new file mode 100644 index 000000000..69d0027e5 --- /dev/null +++ b/packages/internal/tracker/src/types.ts @@ -0,0 +1,11 @@ +export type Optional = Pick, K> & Omit + +export type IdentifyPayload = { + id: string + name?: string | null + email?: string | null + image?: string | null + handle?: string | null +} + +export type Tracker = (code: number, properties?: Record) => Promise diff --git a/packages/internal/tracker/src/utils.ts b/packages/internal/tracker/src/utils.ts new file mode 100644 index 000000000..1933ac415 --- /dev/null +++ b/packages/internal/tracker/src/utils.ts @@ -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 + 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(/^_/, "") +}