refactor(tracker): add tracker package

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-03-25 21:16:54 +08:00
parent 359093dcf1
commit 549d76285a
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
36 changed files with 673 additions and 168 deletions

View File

@ -1,16 +0,0 @@
import { op } from "~/lib/op"
const PREFIX = "app:"
export const hotUpdateDownloadTrack = (version: string) => {
op.track(`${PREFIX}hot-update-download`, { version })
}
export const hotUpdateAppNotSupportTriggerTrack = (data: {
appVersion: string
manifestVersion: string
}) => {
op.track(`${PREFIX}hot-update-app-not-support-trigger`, data)
}
export const hotUpdateRenderSuccessTrack = (version: string) => {
op.track(`${PREFIX}hot-update-render-success`, { version })
}

View File

@ -14,7 +14,6 @@ import { load } from "js-yaml"
import { x } from "tar"
import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app"
import { hotUpdateDownloadTrack, hotUpdateRenderSuccessTrack } from "~/tracker"
import { getMainWindow } from "~/window"
import { appUpdaterConfig } from "./configs"
@ -134,7 +133,6 @@ export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest
return [CanUpdateRenderState.NEEDED, manifest]
}
const downloadRenderAsset = async (manifest: Manifest) => {
hotUpdateDownloadTrack(manifest.version)
const { filename } = manifest
const url = await getFileDownloadUrl(filename)
@ -181,7 +179,7 @@ export const hotUpdateRender = async (manifest: Manifest) => {
JSON.stringify(manifest),
)
logger.info(`Hot update render success, update to ${manifest.version}`)
hotUpdateRenderSuccessTrack(manifest.version)
const mainWindow = getMainWindow()
if (!mainWindow) return false
const caller = callWindowExpose(mainWindow)

View File

@ -18,6 +18,7 @@
"@electron-toolkit/preload": "3.0.1",
"@follow/electron-main": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/tracker": "workspace:*",
"@fontsource/sn-pro": "5.2.5",
"@headlessui/react": "2.2.0",
"@hookform/resolvers": "4.1.3",

View File

@ -1,5 +1,6 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { tracker } from "@follow/tracker"
import { cn, getOS } from "@follow/utils/utils"
import { useEffect } from "react"
import { Outlet } from "react-router"
@ -58,9 +59,7 @@ const AppLayer = () => {
removeAppSkeleton()
const doneTime = Math.trunc(performance.now())
window.analytics?.capture("ui_render_init", {
time: doneTime,
})
tracker.uiRenderInit(doneTime)
appLog("App is ready", `${doneTime}ms`)
applyAfterReadyCallbacks()

View File

@ -1,20 +1,27 @@
import type { TrackerPoints } from "@follow/tracker"
import { tracker } from "@follow/tracker"
import type { AllTrackers } from "@follow/tracker/src/points"
import { memo, useState } from "react"
import { useInView } from "react-intersection-observer"
type ImpressionProps = {
event: string
type ImpressionProps<T extends AllTrackers> = {
event: T
onTrack?: () => any
properties?: Record<string, any>
properties?: Parameters<TrackerPoints[T]>
children: React.ReactNode
}
export const ImpressionView: Component<{ shouldTrack?: boolean } & ImpressionProps> = (props) => {
export function ImpressionView<T extends keyof typeof tracker>(
props: ImpressionProps<T> & { shouldTrack?: boolean },
) {
const { shouldTrack = true, ...rest } = props
if (!shouldTrack) {
return <>{props.children}</>
}
return <ImpressionViewImpl {...rest} />
return <MemoImpressionViewImpl {...rest} />
}
const ImpressionViewImpl: Component<ImpressionProps> = memo((props) => {
function ImpressionViewImpl<T extends keyof typeof tracker>(props: ImpressionProps<T>) {
const [impression, setImpression] = useState(false)
const { ref } = useInView({
@ -26,10 +33,8 @@ const ImpressionViewImpl: Component<ImpressionProps> = memo((props) => {
}
setImpression(true)
window.analytics?.capture(props.event, {
impression: 1,
...props.properties,
})
// @ts-expect-error
tracker[props.event]?.apply(null, props.properties)
props.onTrack?.()
},
})
@ -40,6 +45,6 @@ const ImpressionViewImpl: Component<ImpressionProps> = memo((props) => {
{!impression && <span ref={ref} />}
</>
)
})
ImpressionViewImpl.displayName = "ImpressionView"
}
const MemoImpressionViewImpl = memo(ImpressionViewImpl)
MemoImpressionViewImpl.displayName = "ImpressionView"

View File

@ -2,6 +2,7 @@ import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/at
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { useSheetContext } from "@follow/components/ui/sheet/context.js"
import type { FeedViewType } from "@follow/constants"
import { tracker } from "@follow/tracker"
import { useCallback } from "react"
import { disableShowAISummary } from "~/atoms/ai-summary"
@ -81,13 +82,11 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
disableShowAISummary()
disableShowAITranslation()
if (window.analytics) {
window.analytics.capture("Navigate Entry", {
feedId: finalFeedId,
entryId,
timelineId: finalTimelineId,
})
}
tracker.navigateEntry({
feedId: finalFeedId,
entryId: finalEntryId,
timelineId: finalTimelineId,
})
const path = `/timeline/${finalTimelineId}/${finalFeedId}/${finalEntryId}`

View File

@ -1,4 +1,5 @@
import { env } from "@follow/shared/env.desktop"
import { setOpenPanelTracker } from "@follow/tracker"
import type { TrackProperties } from "@openpanel/web"
import { getGeneralSettings } from "~/atoms/settings/general"
@ -6,14 +7,6 @@ import { whoami } from "~/atoms/user"
import { op } from "./op"
declare global {
interface Window {
analytics?: {
capture: (event_name: string, properties?: TrackProperties | null) => void
reset: () => void
}
}
}
export const initAnalytics = () => {
if (env.VITE_OPENPANEL_CLIENT_ID === undefined) return
@ -23,21 +16,16 @@ export const initAnalytics = () => {
hash: GIT_COMMIT_SHA,
})
window.analytics = {
reset: () => {
// op.clear()
},
capture(event_name: string, properties?: TrackProperties | null) {
if (import.meta.env.DEV) return
if (!getGeneralSettings().sendAnonymousData) {
return
}
const me = whoami()
setOpenPanelTracker(async (name, properties) => {
if (import.meta.env.DEV) return
if (!getGeneralSettings().sendAnonymousData) {
return
}
const me = whoami()
op.track(event_name, {
...properties,
user_id: me?.id,
} as TrackProperties)
},
}
return op.track(name, {
...properties,
user_id: me?.id,
} as TrackProperties)
})
}

View File

@ -1,6 +1,7 @@
import { initializeDayjs } from "@follow/components/dayjs"
import { registerGlobalContext } from "@follow/shared/bridge"
import { IN_ELECTRON } from "@follow/shared/constants"
import { tracker } from "@follow/tracker"
import { repository } from "@pkg"
import { enableMapSet } from "immer"
@ -115,14 +116,14 @@ export const initializeApp = async () => {
const loadingTime = Date.now() - now
appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`)
window.analytics?.capture("app_init", {
tracker.appInit({
electron: IN_ELECTRON,
loading_time: loadingTime,
using_indexed_db: enabledDataPersist,
data_hydrated_time: dataHydratedTime,
version: APP_VERSION,
rn: false,
})
// Options for react-google-recaptcha
window.recaptchaOptions = {
useRecaptchaNet: true,

View File

@ -1,3 +1,4 @@
import { tracker } from "@follow/tracker"
import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner"
@ -48,7 +49,8 @@ export const useBoostFeedMutation = () => {
query.getStatus({ feedId: variables.feedId }).invalidate()
query.getBoosters({ feedId: variables.feedId }).invalidate()
updateFeedBoostStatus(variables.feedId, true)
window.analytics?.capture("boost_sent", {
tracker.boostSent({
amount: variables.amount,
feedId: variables.feedId,
})

View File

@ -7,6 +7,7 @@ import {
SimpleIconsReadwise,
} from "@follow/components/ui/platform-icon/icons.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { tracker } from "@follow/tracker"
import { useMutation, useQuery } from "@tanstack/react-query"
import type { FetchError } from "ofetch"
import { ofetch } from "ofetch"
@ -123,7 +124,7 @@ const useRegisterReadwiseCommands = () => {
return
}
try {
window.analytics?.capture("integration", {
tracker.integration({
type: "readwise",
event: "save",
})
@ -194,7 +195,7 @@ const useRegisterInstapaperCommands = () => {
}
try {
window.analytics?.capture("integration", {
tracker.integration({
type: "instapaper",
event: "save",
})
@ -300,7 +301,7 @@ const useRegisterObsidianCommands = () => {
return
}
const markdownContent = await getEntryContentAsMarkdown(entry)
window.analytics?.capture("integration", {
tracker.integration({
type: "obsidian",
event: "save",
})
@ -407,7 +408,7 @@ const useRegisterReadeckCommands = () => {
return
}
try {
window.analytics?.capture("integration", {
tracker.integration({
type: "readeck",
event: "save",
})

View File

@ -5,6 +5,7 @@ import { ActionButton } from "@follow/components/ui/button/index.js"
import { DividerVertical } from "@follow/components/ui/divider/Divider.js"
import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.js"
import { Slider } from "@follow/components/ui/slider/index.js"
import { tracker } from "@follow/tracker"
import { clsx, cn } from "@follow/utils/utils"
import {
HoverCard,
@ -23,7 +24,6 @@ import {
useSetZenMode,
useUISettingKey,
} from "~/atoms/settings/ui"
import { ImpressionView } from "~/components/common/ImpressionTracker"
import { shortcuts } from "~/constants/shortcuts"
import { useAIDailyReportModal } from "~/modules/ai/ai-daily/useAIDailyReportModal"
@ -34,19 +34,15 @@ export const DailyReportButton: FC = () => {
const { t } = useTranslation()
return (
<ImpressionView event="Daily Report Modal">
<ActionButton
onClick={() => {
present()
window.analytics?.capture("Daily Report Modal", {
click: 1,
})
}}
tooltip={t("entry_list_header.daily_report")}
>
<i className="i-mgc-magic-2-cute-re" />
</ActionButton>
</ImpressionView>
<ActionButton
onClick={() => {
present()
tracker.dailyReportModal()
}}
tooltip={t("entry_list_header.daily_report")}
>
<i className="i-mgc-magic-2-cute-re" />
</ActionButton>
)
}
@ -150,44 +146,34 @@ export const WideModeButton = () => {
const setIsZenMode = useSetZenMode()
return (
<ImpressionView
event="Switch to Wide Mode"
properties={{
wideMode: isWideMode ? 1 : 0,
}}
>
<ActionButton
shortcut={shortcuts.layout.toggleWideMode.key}
onClick={() => {
if (isZenMode) {
setIsZenMode(false)
} else {
setUISetting("wideMode", !isWideMode)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
}
window.analytics?.capture("Switch to Wide Mode", {
wideMode: !isWideMode ? 1 : 0,
click: 1,
})
}}
tooltip={
isZenMode
? t("zen.exit")
: !isWideMode
? t("entry_list_header.switch_to_widemode")
: t("entry_list_header.switch_to_normalmode")
<ActionButton
shortcut={shortcuts.layout.toggleWideMode.key}
onClick={() => {
if (isZenMode) {
setIsZenMode(false)
} else {
setUISetting("wideMode", !isWideMode)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
}
>
{isZenMode ? (
<MdiMeditation />
) : (
<i
className={cn(isWideMode ? "i-mgc-align-justify-cute-re" : "i-mgc-align-left-cute-re")}
/>
)}
</ActionButton>
</ImpressionView>
tracker.wideMode({ mode: isWideMode ? "wide" : "normal" })
}}
tooltip={
isZenMode
? t("zen.exit")
: !isWideMode
? t("entry_list_header.switch_to_widemode")
: t("entry_list_header.switch_to_normalmode")
}
>
{isZenMode ? (
<MdiMeditation />
) : (
<i
className={cn(isWideMode ? "i-mgc-align-justify-cute-re" : "i-mgc-align-left-cute-re")}
/>
)}
</ActionButton>
)
}

View File

@ -1,5 +1,6 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import type { MediaModel } from "@follow/models"
import { tracker } from "@follow/tracker"
import { useTranslation } from "react-i18next"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
@ -15,7 +16,9 @@ export const ImageGalleryAction = ({ id }: { id: string }) => {
return (
<ActionButton
onClick={() => {
window.analytics?.capture("entry_content_header_image_gallery_click")
tracker.entryContentHeaderImageGalleryClick({
feedId: id,
})
present({
title: t("entry_actions.image_gallery"),
content: () => <ImageGallery images={images as any as MediaModel[]} />,

View File

@ -11,6 +11,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
import type { FeedViewType } from "@follow/constants"
import { useInputComposition } from "@follow/hooks"
import { tracker } from "@follow/tracker"
import { clsx, cn } from "@follow/utils/utils"
import { Command } from "cmdk"
import type { FC } from "react"
@ -46,7 +47,8 @@ export const SearchCmdK: React.FC = () => {
React.useEffect(() => {
if (!open) return
window.analytics?.capture("search_open")
tracker.searchOpen()
// Refresh data
setPage(0)
setSearchInstance(() => searchActions.createLocalDbSearch())

View File

@ -1,5 +1,6 @@
import { Form, FormControl, FormField, FormItem } from "@follow/components/ui/form/index.jsx"
import { useRegisterGlobalContext } from "@follow/shared/bridge"
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useLayoutEffect } from "react"
@ -45,7 +46,10 @@ const CmdNPanel = () => {
const defaultView = getRouteParams().view
window.analytics?.capture("quick_add_feed", { url, defaultView })
tracker.quickAddFeed({
type: "url",
defaultView: Number(defaultView),
})
present({
title: t("feed_form.add_feed"),

View File

@ -2,6 +2,7 @@ import { useFocusable } from "@follow/components/common/Focusable.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
import { FeedViewType } from "@follow/constants"
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import * as Slider from "@radix-ui/react-slider"
import dayjs from "dayjs"
@ -76,7 +77,8 @@ const usePlayerTracker = () => {
useEffect(() => {
const handler = () => {
const playerState = getAudioPlayerAtomValue()
window.analytics?.capture("player_open_duration", {
tracker.playerOpenDuration({
duration: Date.now() - playerOpenAt,
status: playerState.status,
trigger: "beforeunload",
@ -90,7 +92,7 @@ const usePlayerTracker = () => {
useEffect(() => {
if (!show) {
const playerState = getAudioPlayerAtomValue()
window.analytics?.capture("player_open_duration", {
tracker.playerOpenDuration({
duration: Date.now() - playerOpenAt,
status: playerState.status,
trigger: "manual",

View File

@ -1,3 +1,4 @@
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import { m, useMotionTemplate, useMotionValue } from "framer-motion"
import { useCallback, useEffect } from "react"
@ -26,7 +27,7 @@ export const UpdateNotice = () => {
const handleClick = useCallback(() => {
const status = getUpdaterStatus()
if (!status) return
window.analytics?.capture("update_restart", {
tracker.updateRestart({
type: status.type,
})
switch (status.type) {

View File

@ -1,3 +1,4 @@
import { tracker } from "@follow/tracker"
import { createElement, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -24,7 +25,7 @@ export const useTipModal = () => {
toast.error("Invalid feed id or entry id")
return
}
window.analytics?.capture("tip_modal_opened", { entryId })
tracker.tipModalOpened({ entryId })
present({
title: t("tip_modal.tip_title"),
content: () => createElement(TipModalContent, { userId, feedId, entryId }),

View File

@ -1,3 +1,4 @@
import { tracker } from "@follow/tracker"
import { formatXml } from "@follow/utils/utils"
import { useMutation } from "@tanstack/react-query"
import { useRef } from "react"
@ -64,7 +65,7 @@ export const useClaimFeedMutation = (feedId: string) =>
toastFetchError(err)
},
onSuccess() {
window.analytics?.capture("feed_claimed", {
tracker.feedClaimed({
feedId,
})
},

View File

@ -1,3 +1,4 @@
import { tracker } from "@follow/tracker"
import { useMutation } from "@tanstack/react-query"
import { useNavigate } from "react-router"
import { toast } from "sonner"
@ -109,7 +110,7 @@ export const useClaimWalletDailyRewardMutation = () => {
onSuccess() {
wallet.get().invalidate()
wallet.claimCheck().invalidate()
window.analytics?.capture("daily_reward_claimed")
tracker.dailyRewardClaimed()
toast(
<div className="flex items-center gap-1 text-lg" onClick={() => navigate("/power")}>
@ -138,7 +139,7 @@ export const useWalletTipMutation = () =>
onSuccess(_, variables) {
wallet.get().invalidate()
wallet.transactions.get().invalidate()
window.analytics?.capture("tip_sent", {
tracker.tipSent({
amount: variables.amount,
entryId: variables.entryId,
})

View File

@ -29,6 +29,7 @@
"@follow/utils": "workspace:*",
"@gorhom/portal": "1.0.14",
"@hookform/resolvers": "4.1.3",
"@openpanel/sdk": "1.0.0",
"@react-native-community/image-editor": "4.3.0",
"@react-native-firebase/analytics": "21.12.0",
"@react-native-firebase/app": "21.12.0",
@ -47,6 +48,7 @@
"es-toolkit": "1.33.0",
"expo": "52.0.39",
"expo-apple-authentication": "7.1.3",
"expo-application": "6.0.2",
"expo-av": "15.0.2",
"expo-blur": "14.0.3",
"expo-build-properties": "0.13.2",

View File

@ -6,9 +6,9 @@ import { forwardRef, useCallback, useMemo, useState } from "react"
import { proxyEnv } from "@/src/lib/proxy-env"
const buildSafeHeaders = createBuildSafeHeaders(proxyEnv.VITE_WEB_URL, [
const buildSafeHeaders = createBuildSafeHeaders(proxyEnv.WEB_URL, [
IMAGE_PROXY_URL,
proxyEnv.VITE_API_URL,
proxyEnv.API_URL,
])
export type ImageProps = Omit<ExpoImageProps, "source"> & {

View File

@ -1,9 +0,0 @@
import { OpenPanel } from "@openpanel/web"
export const op = new OpenPanel({
clientId: process.env.EXPO_PUBLIC_OPENPANEL_CLIENT_ID ?? "",
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
apiUrl: process.env.EXPO_PUBLIC_OPENPANEL_API_URL,
})

View File

@ -11,7 +11,7 @@ const { hc } = require("hono/dist/cjs/client/client") as typeof import("hono/cli
export const apiFetch = ofetch.create({
retry: false,
baseURL: proxyEnv.VITE_API_URL,
baseURL: proxyEnv.API_URL,
onRequest: async (ctx) => {
const { options, request } = ctx
if (__DEV__) {
@ -47,7 +47,7 @@ export const apiFetch = ofetch.create({
},
})
export const apiClient = hc<AppType>(proxyEnv.VITE_API_URL, {
export const apiClient = hc<AppType>(proxyEnv.API_URL, {
fetch: async (input: any, options = {}) =>
apiFetch(input.toString(), options).catch((err) => {
throw err

View File

@ -41,7 +41,7 @@ const serverPlugins = [
] satisfies BetterAuthClientPlugin[]
const authClient = createAuthClient({
baseURL: `${proxyEnv.VITE_API_URL}/better-auth`,
baseURL: `${proxyEnv.API_URL}/better-auth`,
plugins: [
twoFactorClient(),
{

10
apps/mobile/src/lib/op.ts Normal file
View File

@ -0,0 +1,10 @@
import { OpenPanel } from "@openpanel/sdk"
import { proxyEnv } from "./proxy-env"
export const op = new OpenPanel({
clientId: proxyEnv.OPENPANEL_CLIENT_ID ?? "",
apiUrl: proxyEnv.OPENPANEL_API_URL,
// waitForProfile: true,
})

View File

@ -1,5 +1,5 @@
import type { env } from "@follow/shared/src/env.rn"
import { envProfileMap } from "@follow/shared/src/env.rn"
import type { env, envProfileMap } from "@follow/shared/src/env.rn"
import { getEnvProfiles__dangerously } from "@follow/shared/src/env.rn"
import { createAtomHooks } from "@follow/utils"
import { reloadAppAsync } from "expo"
import { atomWithStorage } from "jotai/utils"
@ -21,7 +21,10 @@ export const proxyEnv = new Proxy(
{
get(target, prop) {
const profile = getEnvProfile() as keyof typeof envProfileMap
return envProfileMap[profile][prop as keyof (typeof envProfileMap)[typeof profile]]
const envProfiles = getEnvProfiles__dangerously()
const envMap = envProfiles[profile]
return envMap[prop as keyof typeof envMap]
},
},
) as any as typeof env

View File

@ -1,4 +1,4 @@
import Constants from "expo-constants"
import { nativeApplicationVersion, nativeBuildVersion } from "expo-application"
import { Linking, Text, View } from "react-native"
import { Link } from "@/src/components/common/Link"
@ -43,8 +43,8 @@ const links = [
]
export const AboutScreen = () => {
const buildId = Constants.expoConfig?.extra?.eas?.buildId || "Development"
const appVersion = Constants.expoConfig?.version || "0.0.0"
const buildId = nativeBuildVersion
const appVersion = nativeApplicationVersion
return (
<SafeNavigationScrollView className="bg-system-grouped-background" contentViewClassName="pt-6">

View File

@ -4,6 +4,7 @@ import { useEffect } from "react"
import { apiClient } from "@/src/lib/api-fetch"
import { kv } from "@/src/lib/kv"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { op } from "@/src/lib/op"
import { OnboardingScreen } from "@/src/screens/onboarding"
import { isNewUserQueryKey, isOnboardingFinishedStorageKey } from "./constants"
@ -12,10 +13,30 @@ import { userSyncService, useUserStore } from "./store"
export const whoamiQueryKey = ["user", "whoami"]
export const usePrefetchSessionUser = () => {
useQuery({
const query = useQuery({
queryKey: whoamiQueryKey,
queryFn: () => userSyncService.whoami(),
})
useEffect(() => {
if (query.data) {
const user = query.data
op.identify({
profileId: user.id,
email: user.email!,
avatar: user.image ?? undefined,
lastName: user.name!,
properties: {
handle: user.handle,
name: user.name,
},
})
op.track("user_login", {
userId: query.data.id,
})
}
}, [query.data])
return query
}
export const useWhoami = () => {

View File

@ -3,26 +3,36 @@
*/
const profile = "prod"
export const envProfileMap = {
const envProfileMap = {
prod: {
VITE_API_URL: "https://api.follow.is",
VITE_WEB_URL: "https://app.follow.is",
VITE_INBOXES_EMAIL: "@follow.re",
API_URL: "https://api.follow.is",
WEB_URL: "https://app.follow.is",
INBOXES_EMAIL: "@follow.re",
OPENPANEL_CLIENT_ID: "0e477ab4-d92d-4d6e-b889-b09d86ab908e",
OPENPANEL_API_URL: "https://openpanel.follow.is/api",
},
dev: {
VITE_API_URL: "https://api.dev.follow.is",
VITE_WEB_URL: "https://dev.follow.is",
VITE_INBOXES_EMAIL: "__devdev@follow.re",
API_URL: "https://api.dev.follow.is",
WEB_URL: "https://dev.follow.is",
INBOXES_EMAIL: "__dev@follow.re",
},
staging: {
VITE_API_URL: "https://api.follow.is",
VITE_WEB_URL: "https://staging.follow.is",
VITE_INBOXES_EMAIL: "@follow.re",
API_URL: "https://api.follow.is",
WEB_URL: "https://staging.follow.is",
INBOXES_EMAIL: "@follow.re",
OPENPANEL_CLIENT_ID: "0e477ab4-d92d-4d6e-b889-b09d86ab908e",
OPENPANEL_API_URL: "https://openpanel.follow.is/api",
},
}
export const getEnvProfiles__dangerously = () => envProfileMap
export type { envProfileMap }
/**
* @description this env always use prod env, please use `proxyEnv` to access dynamic env
*/
export const env = {
VITE_WEB_URL: envProfileMap[profile].VITE_WEB_URL,
VITE_API_URL: envProfileMap[profile].VITE_API_URL,
WEB_URL: envProfileMap[profile].WEB_URL,
API_URL: envProfileMap[profile].API_URL,
APP_CHECK_DEBUG_TOKEN: process.env.EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN,
OPENPANEL_CLIENT_ID: envProfileMap[profile].OPENPANEL_CLIENT_ID,
OPENPANEL_API_URL: envProfileMap[profile].OPENPANEL_API_URL,
}

View File

@ -0,0 +1,6 @@
{
"name": "@follow/tracker",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts"
}

View File

@ -0,0 +1,8 @@
import { TrackerPoints, trackManager } from "./points"
export const setOpenPanelTracker = trackManager.setTrackFn.bind(trackManager)
export const tracker = new TrackerPoints()
export { type TrackerPoints } from "./points"
export { TrackerMapper } from "./points"

View File

@ -0,0 +1,84 @@
interface ApiConfig {
baseUrl: string
defaultHeaders?: Record<string, string | Promise<string | null>>
maxRetries?: number
initialRetryDelay?: number
headers?: Record<string, string | Promise<string | null>>
}
interface FetchOptions extends RequestInit {
retries?: number
}
export class Api {
private baseUrl: string
private headers: Record<string, string | Promise<string | null>>
private maxRetries: number
private initialRetryDelay: number
constructor(config: ApiConfig) {
this.baseUrl = config.baseUrl
this.headers = {
"Content-Type": "application/json",
...config.defaultHeaders,
...config.headers,
}
this.maxRetries = config.maxRetries ?? 3
this.initialRetryDelay = config.initialRetryDelay ?? 500
}
private async resolveHeaders(): Promise<Record<string, string>> {
const resolvedHeaders: Record<string, string> = {}
for (const [key, value] of Object.entries(this.headers)) {
const resolvedValue = await value
if (resolvedValue !== null) {
resolvedHeaders[key] = resolvedValue
}
}
return resolvedHeaders
}
private async post<ReqBody, ResBody>(
url: string,
data: ReqBody,
options: FetchOptions,
attempt: number,
): Promise<ResBody | null> {
try {
const response = await fetch(url, {
method: "POST",
headers: await this.resolveHeaders(),
body: JSON.stringify(data ?? {}),
keepalive: true,
...options,
})
if (response.status === 401) return null
if (response.status !== 200 && response.status !== 202) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const responseText = await response.text()
return responseText ? JSON.parse(responseText) : null
} catch (error) {
if (attempt < this.maxRetries) {
const delay = this.initialRetryDelay * 2 ** attempt
await new Promise((resolve) => setTimeout(resolve, delay))
return this.post<ReqBody, ResBody>(url, data, options, attempt + 1)
}
console.error("Max retries reached:", error)
return null
}
}
async fetch<ReqBody, ResBody>(
path: string,
data: ReqBody,
options: FetchOptions = {},
): Promise<ResBody | null> {
const url = `${this.baseUrl}${path}`
return this.post<ReqBody, ResBody>(url, data, options, 0)
}
}

View File

@ -0,0 +1,200 @@
import { Api } from "./api"
export type TrackHandlerPayload =
| {
type: "track"
payload: TrackPayload
}
| {
type: "increment"
payload: IncrementPayload
}
| {
type: "decrement"
payload: DecrementPayload
}
| {
type: "alias"
payload: AliasPayload
}
| {
type: "identify"
payload: IdentifyPayload
}
export type TrackPayload = {
name: string
properties?: Record<string, unknown>
profileId?: string
}
export type TrackProperties = {
[key: string]: unknown
profileId?: string
}
export type IdentifyPayload = {
profileId: string
firstName?: string
lastName?: string
email?: string
avatar?: string
properties?: Record<string, unknown>
}
export type AliasPayload = {
profileId: string
alias: string
}
export type IncrementPayload = {
profileId: string
property: string
value?: number
}
export type DecrementPayload = {
profileId: string
property: string
value?: number
}
export type OpenPanelOptions = {
clientId: string
clientSecret?: string
apiUrl?: string
sdk?: string
sdkVersion?: string
waitForProfile?: boolean
filter?: (payload: TrackHandlerPayload) => boolean
disabled?: boolean
}
export class OpenPanel {
api: Api
profileId?: string
global?: Record<string, unknown>
queue: TrackHandlerPayload[] = []
constructor(public options: OpenPanelOptions) {
const defaultHeaders: Record<string, string> = {
"openpanel-client-id": options.clientId,
}
if (options.clientSecret) {
defaultHeaders["openpanel-client-secret"] = options.clientSecret
}
defaultHeaders["openpanel-sdk-name"] = options.sdk || "node"
defaultHeaders["openpanel-sdk-version"] = options.sdkVersion || process.env.SDK_VERSION!
this.api = new Api({
baseUrl: options.apiUrl || "https://api.openpanel.dev",
defaultHeaders,
})
}
ready() {
this.options.waitForProfile = false
this.flush()
}
async send(payload: TrackHandlerPayload) {
if (this.options.disabled) {
return
}
// eslint-disable-next-line unicorn/no-array-callback-reference
if (this.options.filter && !this.options.filter(payload)) {
return
}
if (this.options.waitForProfile && !this.profileId) {
this.queue.push(payload)
return
}
return this.api.fetch("/track", payload)
}
setGlobalProperties(properties: Record<string, unknown>) {
this.global = {
...this.global,
...properties,
}
}
async track(name: string, properties?: TrackProperties) {
return this.send({
type: "track",
payload: {
name,
profileId: properties?.profileId ?? this.profileId,
properties: {
...this.global,
...properties,
},
},
})
}
async identify(payload: IdentifyPayload) {
if (payload.profileId) {
this.profileId = payload.profileId
this.flush()
}
if (Object.keys(payload).length > 1) {
return this.send({
type: "identify",
payload: {
...payload,
properties: {
...this.global,
...payload.properties,
},
},
})
}
}
async alias(payload: AliasPayload) {
return this.send({
type: "alias",
payload,
})
}
async increment(payload: IncrementPayload) {
return this.send({
type: "increment",
payload,
})
}
async decrement(payload: DecrementPayload) {
return this.send({
type: "decrement",
payload,
})
}
clear() {
this.profileId = undefined
// should we force a session end here?
}
flush() {
this.queue.forEach((item) => {
this.send({
...item,
// Not sure why ts-expect-error is needed here
// @ts-expect-error
payload: {
...item.payload,
profileId: item.payload.profileId ?? this.profileId,
},
})
})
this.queue = []
}
}

View File

@ -0,0 +1,164 @@
import type { OpenPanel } from "./op"
type Tracker = (name: string, properties?: Record<string, unknown>) => Promise<any>
class TrackManager {
private trackFns: Tracker[] = []
setTrackFn(fn: Tracker) {
this.trackFns.push(fn)
return () => {
this.trackFns = this.trackFns.filter((t) => t !== fn)
}
}
getTrackFn(): Tracker {
if (this.trackFns.length === 0) {
console.error("[Tracker warn]: Track function not set")
}
return (name, properties) => {
return Promise.all(this.trackFns.map((fn) => fn(name, properties)))
}
}
setOpenPanelTracker(tracker: OpenPanel["track"]) {
this.trackFns.push((name, properties) => tracker(name, properties))
}
}
export const trackManager = new TrackManager()
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,
}
const CodeToTrackerName = Object.fromEntries(
Object.entries(TrackerMapper).map(([key, value]) => [value, key]),
) as Record<number, string>
export class TrackerPoints {
// App
identify(userId: string) {
this.track(TrackerMapper.Identify, { userId })
}
appInit(props: {
electron?: boolean
rn?: boolean
loading_time?: number
using_indexed_db?: boolean
data_hydrated_time?: number
version?: string
}) {
this.track(TrackerMapper.AppInit, props)
}
userLogin(userId: string) {
this.track(TrackerMapper.UserLogin, { userId })
}
/**
* For desktop UI only
*/
uiRenderInit(spentTime: number) {
this.track(TrackerMapper.UiRenderInit, { time: spentTime })
}
navigateEntry(props: { feedId?: string; entryId?: string; timelineId?: string }) {
this.track(TrackerMapper.NavigateEntry, props)
}
boostSent(props: { amount: string; feedId: string }) {
this.track(TrackerMapper.BoostSent, props)
}
integration(props: { type: string; event: string }) {
this.track(TrackerMapper.Integration, props)
}
dailyReportModal() {
this.track(TrackerMapper.DailyReportModal)
}
switchToMasonry() {
this.track(TrackerMapper.SwitchToMasonry)
}
wideMode(props: { mode: "wide" | "normal" }) {
this.track(TrackerMapper.WideMode, props)
}
entryContentHeaderImageGalleryClick(props: { feedId?: string }) {
this.track(TrackerMapper.EntryContentHeaderImageGalleryClick, props)
}
searchOpen() {
this.track(TrackerMapper.SearchOpen)
}
quickAddFeed(props: { type: "url" | "search"; defaultView: number }) {
this.track(TrackerMapper.QuickAddFeed, props)
}
playerOpenDuration(props: {
duration: number
status?: "playing" | "loading" | "paused"
trigger?: "manual" | "beforeunload"
}) {
this.track(TrackerMapper.PlayerOpenDuration, props)
}
updateRestart(props: { type: "app" | "renderer" | "pwa" }) {
this.track(TrackerMapper.UpdateRestart, props)
}
tipModalOpened(props: { entryId?: string }) {
this.track(TrackerMapper.TipModalOpened, props)
}
feedClaimed(props: { feedId: string }) {
this.track(TrackerMapper.FeedClaimed, props)
}
dailyRewardClaimed() {
this.track(TrackerMapper.DailyRewardClaimed)
}
tipSent(props: { amount: string; entryId: string }) {
this.track(TrackerMapper.TipSent, props)
}
private track(code: TrackerMapper, properties?: Record<string, unknown>) {
if (code) {
let name = CodeToTrackerName[code]
if (name) {
name = snakeCase(name)
Reflect.apply(trackManager.getTrackFn(), null, [name, { ...properties, __code: code }])
}
}
}
}
const snakeCase = (string: string) => {
return string
.replaceAll(/\W+/g, " ")
.split(/ |\B(?=[A-Z])/)
.map((word) => word.toLowerCase())
.join("_")
}
export type AllTrackers = keyof TrackerPoints

View File

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.extend.json",
"compilerOptions": {
"baseUrl": ".",
"declaration": false
}
}

View File

@ -481,6 +481,9 @@ importers:
'@follow/shared':
specifier: workspace:*
version: link:../../../../packages/shared
'@follow/tracker':
specifier: workspace:*
version: link:../../../../packages/tracker
'@fontsource/sn-pro':
specifier: 5.2.5
version: 5.2.5
@ -788,6 +791,9 @@ importers:
'@hookform/resolvers':
specifier: 4.1.3
version: 4.1.3(react-hook-form@7.54.2(react@18.3.1))
'@openpanel/sdk':
specifier: 1.0.0
version: 1.0.0
'@react-native-community/image-editor':
specifier: 4.3.0
version: 4.3.0(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))
@ -842,6 +848,9 @@ importers:
expo-apple-authentication:
specifier: 7.1.3
version: 7.1.3(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))
expo-application:
specifier: 6.0.2
version: 6.0.2(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5))
expo-av:
specifier: 15.0.2
version: 15.0.2(1c0838ca83e190af6509d6d21281a8a6)
@ -1536,6 +1545,8 @@ importers:
specifier: 3.24.2
version: 3.24.2
packages/tracker: {}
packages/types: {}
packages/utils:
@ -9153,6 +9164,11 @@ packages:
expo: '*'
react-native: '*'
expo-application@6.0.2:
resolution: {integrity: sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A==}
peerDependencies:
expo: '*'
expo-asset@11.0.5:
resolution: {integrity: sha512-TL60LmMBGVzs3NQcO8ylWqBumMh4sx0lmeJsn7+9C88fylGDhyyVnKZ1PyTXo9CVDBkndutZx2JUEQWM9BaiXw==}
peerDependencies:
@ -25941,6 +25957,10 @@ snapshots:
expo: 52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)
react-native: 0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)
expo-application@6.0.2(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)):
dependencies:
expo: 52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)
expo-asset@11.0.5(expo@52.0.35(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1):
dependencies:
'@expo/image-utils': 0.6.5