feat: product analytic (#5099)

* feat: product analytic

* chore: remove comment
This commit is contained in:
Faisal Amir 2025-05-25 17:25:03 +07:00 committed by GitHub
parent 484caf04aa
commit b29e579042
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 305 additions and 57 deletions

View File

@ -46,6 +46,7 @@
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"motion": "^12.10.5", "motion": "^12.10.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"posthog-js": "^1.246.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@ -11,4 +11,6 @@ export const localStorageKey = {
settingMCPSevers: 'setting-mcp-servers', settingMCPSevers: 'setting-mcp-servers',
settingLocalApiServer: 'setting-local-api-server', settingLocalApiServer: 'setting-local-api-server',
settingHardware: 'setting-hardware', settingHardware: 'setting-hardware',
productAnalyticPrompt: 'productAnalyticPrompt',
productAnalytic: 'productAnalytic',
} }

View File

@ -0,0 +1,50 @@
import { Button } from '@/components/ui/button'
import { useAnalytic } from '@/hooks/useAnalytic'
import { IconFileTextShield } from '@tabler/icons-react'
import posthog from 'posthog-js'
export function PromptAnalytic() {
const { setProductAnalyticPrompt, setProductAnalytic } = useAnalytic()
const handleProductAnalytics = (isAllowed: boolean) => {
if (isAllowed) {
posthog.opt_in_capturing()
setProductAnalytic(true)
setProductAnalyticPrompt(false)
} else {
posthog.opt_out_capturing()
setProductAnalytic(false)
setProductAnalyticPrompt(false)
}
}
return (
<div className="fixed bottom-4 right-4 z-50 p-4 shadow-lg bg-main-view w-100 border border-main-view-fg/8 rounded-lg">
<div className="flex items-center gap-2">
<IconFileTextShield className="text-accent" />
<h2 className="font-medium text-main-view-fg/80">
Help Us Improve Jan
</h2>
</div>
<p className="mt-2 text-sm text-main-view-fg/70">
We collect anonymous data to understand feature usage. Your chats and
personal information are never tracked. You can change this anytime
in&nbsp;
<span className="font-medium text-main-view-fg">{`Settings > Privacy.`}</span>
</p>
<p className="mt-2 text-sm text-main-view-fg/80">
Would you like to help us to improve Jan?
</p>
<div className="mt-4 flex justify-end space-x-2">
<Button
variant="link"
className="text-main-view-fg/70"
onClick={() => handleProductAnalytics(false)}
>
Deny
</Button>
<Button onClick={() => handleProductAnalytics(true)}>Allow</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { localStorageKey } from '@/constants/localStorage'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
export const useAnalytic = () => {
const { productAnalyticPrompt, setProductAnalyticPrompt } =
useProductAnalyticPrompt()
const { productAnalytic, setProductAnalytic } = useProductAnalytic()
const updateAnalytic = ({
productAnalyticPrompt,
productAnalytic,
}: {
productAnalyticPrompt: boolean
productAnalytic: boolean
}) => {
setProductAnalyticPrompt(productAnalyticPrompt)
setProductAnalytic(productAnalytic)
}
return {
productAnalyticPrompt,
setProductAnalyticPrompt,
productAnalytic,
setProductAnalytic,
updateAnalytic,
}
}
export type ProductAnalyticPromptState = {
productAnalyticPrompt: boolean
setProductAnalyticPrompt: (value: boolean) => void
}
export const useProductAnalyticPrompt = create<ProductAnalyticPromptState>()(
persist(
(set) => {
const initialState = {
productAnalyticPrompt: true,
setProductAnalyticPrompt: async (value: boolean) => {
set(() => ({ productAnalyticPrompt: value }))
},
}
return initialState
},
{
name: localStorageKey.productAnalyticPrompt,
storage: createJSONStorage(() => localStorage),
}
)
)
export type ProductAnalyticState = {
productAnalytic: boolean
setProductAnalytic: (value: boolean) => void
}
export const useProductAnalytic = create<ProductAnalyticState>()(
persist(
(set) => {
const initialState = {
productAnalytic: false,
setProductAnalytic: async (value: boolean) => {
set(() => ({ productAnalytic: value }))
},
}
return initialState
},
{
name: localStorageKey.productAnalytic,
storage: createJSONStorage(() => localStorage),
}
)
)

View File

@ -0,0 +1,65 @@
import posthog from 'posthog-js'
import { useEffect } from 'react'
import { getAppDistinctId, updateDistinctId } from '@/services/analytic'
import { useAnalytic } from '@/hooks/useAnalytic'
export function AnalyticProvider() {
const { productAnalytic } = useAnalytic()
useEffect(() => {
if (!POSTHOG_KEY || !POSTHOG_HOST) {
console.warn(
'PostHog not initialized: Missing POSTHOG_KEY or POSTHOG_HOST environment variables'
)
return
}
if (productAnalytic) {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
disable_session_recording: true,
person_profiles: 'always',
persistence: 'localStorage',
opt_out_capturing_by_default: true,
sanitize_properties: function (properties) {
const denylist = [
'$pathname',
'$initial_pathname',
'$current_url',
'$initial_current_url',
'$host',
'$initial_host',
'$initial_person_info',
]
denylist.forEach((key) => {
if (properties[key]) {
properties[key] = null // Set each denied property to null
}
})
return properties
},
})
// Attempt to restore distinct Id from app global settings
getAppDistinctId()
.then((id) => {
if (id) posthog.identify(id)
})
.finally(() => {
posthog.opt_in_capturing()
posthog.register({ app_version: VERSION })
updateDistinctId(posthog.get_distinct_id())
})
} else {
posthog.opt_out_capturing()
}
}, [productAnalytic])
// This component doesn't render anything
return null
}

View File

@ -12,14 +12,20 @@ import { DataProvider } from '@/providers/DataProvider'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import { ExtensionProvider } from '@/providers/ExtensionProvider' import { ExtensionProvider } from '@/providers/ExtensionProvider'
import { ToasterProvider } from '@/providers/ToasterProvider' import { ToasterProvider } from '@/providers/ToasterProvider'
import { useAnalytic } from '@/hooks/useAnalytic'
import { PromptAnalytic } from '@/containers/analytics/PromptAnalytic'
import { AnalyticProvider } from '@/providers/AnalyticProvider'
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
}) })
const AppLayout = () => { const AppLayout = () => {
const { productAnalyticPrompt } = useAnalytic()
return ( return (
<Fragment> <Fragment>
<AnalyticProvider />
<KeyboardShortcutsProvider /> <KeyboardShortcutsProvider />
<main className="relative h-svh text-sm antialiased select-none bg-app"> <main className="relative h-svh text-sm antialiased select-none bg-app">
{/* Fake absolute panel top to enable window drag */} {/* Fake absolute panel top to enable window drag */}
@ -37,6 +43,7 @@ const AppLayout = () => {
</div> </div>
</div> </div>
</main> </main>
{productAnalyticPrompt && <PromptAnalytic />}
</Fragment> </Fragment>
) )
} }

View File

@ -5,6 +5,8 @@ import HeaderPage from '@/containers/HeaderPage'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Card, CardItem } from '@/containers/Card' import { Card, CardItem } from '@/containers/Card'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAnalytic } from '@/hooks/useAnalytic'
import posthog from 'posthog-js'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.privacy as any)({ export const Route = createFileRoute(route.settings.privacy as any)({
@ -13,6 +15,7 @@ export const Route = createFileRoute(route.settings.privacy as any)({
function Privacy() { function Privacy() {
const { t } = useTranslation() const { t } = useTranslation()
const { setProductAnalytic, productAnalytic } = useAnalytic()
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@ -30,7 +33,17 @@ function Privacy() {
Analytics Analytics
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch /> <Switch
checked={productAnalytic}
onCheckedChange={(state) => {
if (state) {
posthog.opt_in_capturing()
} else {
posthog.opt_out_capturing()
}
setProductAnalytic(state)
}}
/>
</div> </div>
</div> </div>
} }

View File

@ -0,0 +1,24 @@
import { AppConfiguration } from '@janhq/core'
/**
* Update app distinct Id
* @param id
*/
export const updateDistinctId = async (id: string) => {
const appConfiguration: AppConfiguration =
await window.core?.api?.getAppConfigurations()
appConfiguration.distinct_id = id
await window.core?.api?.updateAppConfiguration({
configuration: appConfiguration,
})
}
/**
* Retrieve app distinct Id
* @param id
*/
export const getAppDistinctId = async (): Promise<string | undefined> => {
const appConfiguration: AppConfiguration =
await window.core?.api?.getAppConfigurations()
return appConfiguration.distinct_id
}

View File

@ -16,6 +16,8 @@ declare global {
declare const IS_ANDROID: boolean declare const IS_ANDROID: boolean
declare const PLATFORM: string declare const PLATFORM: string
declare const VERSION: string declare const VERSION: string
declare const POSTHOG_KEY: string
declare const POSTHOG_HOST: string
interface Window { interface Window {
core: AppCore | undefined core: AppCore | undefined
} }

View File

@ -1,4 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
@ -8,61 +8,69 @@ import packageJson from './package.json'
const host = process.env.TAURI_DEV_HOST const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ // Load env file based on `mode` in the current working directory.
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), const env = loadEnv(mode, process.cwd(), '')
react(),
tailwindcss(),
nodePolyfills({
include: ['path'],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
define: {
IS_TAURI: JSON.stringify(process.env.IS_TAURI),
IS_MACOS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('darwin') ?? 'false'
),
IS_WINDOWS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('windows') ?? 'false'
),
IS_LINUX: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('unix') ?? 'false'
),
IS_IOS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('ios') ?? 'false'
),
IS_ANDROID: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('android') ?? 'false'
),
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
VERSION: JSON.stringify(packageJson.version), return {
}, plugins: [
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` react(),
// tailwindcss(),
// 1. prevent vite from obscuring rust errors nodePolyfills({
clearScreen: false, include: ['path'],
// 2. tauri expects a fixed port, fail if that port is not available }),
server: { ],
port: 1420, resolve: {
strictPort: true, alias: {
host: host || false, '@': path.resolve(__dirname, './src'),
hmr: host },
? {
protocol: 'ws',
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**'],
}, },
}, define: {
IS_TAURI: JSON.stringify(process.env.IS_TAURI),
IS_MACOS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('darwin') ?? 'false'
),
IS_WINDOWS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('windows') ?? 'false'
),
IS_LINUX: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('unix') ?? 'false'
),
IS_IOS: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('ios') ?? 'false'
),
IS_ANDROID: JSON.stringify(
process.env.TAURI_ENV_PLATFORM?.includes('android') ?? 'false'
),
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
VERSION: JSON.stringify(packageJson.version),
POSTHOG_KEY: JSON.stringify(env.POSTHOG_KEY),
POSTHOG_HOST: JSON.stringify(env.POSTHOG_HOST),
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: 'ws',
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**'],
},
},
}
}) })