diff --git a/web-app/package.json b/web-app/package.json
index 2057377c9..8b8e736ad 100644
--- a/web-app/package.json
+++ b/web-app/package.json
@@ -46,6 +46,7 @@
"lucide-react": "^0.503.0",
"motion": "^12.10.5",
"next-themes": "^0.4.6",
+ "posthog-js": "^1.246.0",
"react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0",
diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts
index 06ba50fbd..56ce0cdf6 100644
--- a/web-app/src/constants/localStorage.ts
+++ b/web-app/src/constants/localStorage.ts
@@ -11,4 +11,6 @@ export const localStorageKey = {
settingMCPSevers: 'setting-mcp-servers',
settingLocalApiServer: 'setting-local-api-server',
settingHardware: 'setting-hardware',
+ productAnalyticPrompt: 'productAnalyticPrompt',
+ productAnalytic: 'productAnalytic',
}
diff --git a/web-app/src/containers/analytics/PromptAnalytic.tsx b/web-app/src/containers/analytics/PromptAnalytic.tsx
new file mode 100644
index 000000000..cedb65f80
--- /dev/null
+++ b/web-app/src/containers/analytics/PromptAnalytic.tsx
@@ -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 (
+
+
+
+
+ Help Us Improve Jan
+
+
+
+ We collect anonymous data to understand feature usage. Your chats and
+ personal information are never tracked. You can change this anytime
+ in
+ {`Settings > Privacy.`}
+
+
+ Would you like to help us to improve Jan?
+
+
+ handleProductAnalytics(false)}
+ >
+ Deny
+
+ handleProductAnalytics(true)}>Allow
+
+
+ )
+}
diff --git a/web-app/src/hooks/useAnalytic.ts b/web-app/src/hooks/useAnalytic.ts
new file mode 100644
index 000000000..0205ff1a5
--- /dev/null
+++ b/web-app/src/hooks/useAnalytic.ts
@@ -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()(
+ 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()(
+ persist(
+ (set) => {
+ const initialState = {
+ productAnalytic: false,
+ setProductAnalytic: async (value: boolean) => {
+ set(() => ({ productAnalytic: value }))
+ },
+ }
+
+ return initialState
+ },
+ {
+ name: localStorageKey.productAnalytic,
+ storage: createJSONStorage(() => localStorage),
+ }
+ )
+)
diff --git a/web-app/src/providers/AnalyticProvider.tsx b/web-app/src/providers/AnalyticProvider.tsx
new file mode 100644
index 000000000..5bacfb48f
--- /dev/null
+++ b/web-app/src/providers/AnalyticProvider.tsx
@@ -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
+}
diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx
index dc1773a67..0e84e70bb 100644
--- a/web-app/src/routes/__root.tsx
+++ b/web-app/src/routes/__root.tsx
@@ -12,14 +12,20 @@ import { DataProvider } from '@/providers/DataProvider'
import { route } from '@/constants/routes'
import { ExtensionProvider } from '@/providers/ExtensionProvider'
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({
component: RootLayout,
})
const AppLayout = () => {
+ const { productAnalyticPrompt } = useAnalytic()
+
return (
+
{/* Fake absolute panel top to enable window drag */}
@@ -37,6 +43,7 @@ const AppLayout = () => {
+ {productAnalyticPrompt && }
)
}
diff --git a/web-app/src/routes/settings/privacy.tsx b/web-app/src/routes/settings/privacy.tsx
index bc898e5b4..27b21e562 100644
--- a/web-app/src/routes/settings/privacy.tsx
+++ b/web-app/src/routes/settings/privacy.tsx
@@ -5,6 +5,8 @@ import HeaderPage from '@/containers/HeaderPage'
import { Switch } from '@/components/ui/switch'
import { Card, CardItem } from '@/containers/Card'
import { useTranslation } from 'react-i18next'
+import { useAnalytic } from '@/hooks/useAnalytic'
+import posthog from 'posthog-js'
// eslint-disable-next-line @typescript-eslint/no-explicit-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() {
const { t } = useTranslation()
+ const { setProductAnalytic, productAnalytic } = useAnalytic()
return (
@@ -30,7 +33,17 @@ function Privacy() {
Analytics
-
+ {
+ if (state) {
+ posthog.opt_in_capturing()
+ } else {
+ posthog.opt_out_capturing()
+ }
+ setProductAnalytic(state)
+ }}
+ />
}
diff --git a/web-app/src/services/analytic.ts b/web-app/src/services/analytic.ts
new file mode 100644
index 000000000..aaf568f52
--- /dev/null
+++ b/web-app/src/services/analytic.ts
@@ -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 => {
+ const appConfiguration: AppConfiguration =
+ await window.core?.api?.getAppConfigurations()
+ return appConfiguration.distinct_id
+}
diff --git a/web-app/src/types/global.d.ts b/web-app/src/types/global.d.ts
index d97e54866..abf13becd 100644
--- a/web-app/src/types/global.d.ts
+++ b/web-app/src/types/global.d.ts
@@ -16,6 +16,8 @@ declare global {
declare const IS_ANDROID: boolean
declare const PLATFORM: string
declare const VERSION: string
+ declare const POSTHOG_KEY: string
+ declare const POSTHOG_HOST: string
interface Window {
core: AppCore | undefined
}
diff --git a/web-app/vite.config.ts b/web-app/vite.config.ts
index c8e69ec21..9c3b6f4e0 100644
--- a/web-app/vite.config.ts
+++ b/web-app/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
@@ -8,61 +8,69 @@ import packageJson from './package.json'
const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/
-export default defineConfig({
- plugins: [
- TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
- 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),
+export default defineConfig(({ mode }) => {
+ // Load env file based on `mode` in the current working directory.
+ const env = loadEnv(mode, process.cwd(), '')
- VERSION: JSON.stringify(packageJson.version),
- },
-
- // 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/**'],
+ return {
+ plugins: [
+ TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
+ 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),
+
+ 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/**'],
+ },
+ },
+ }
})