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? +

+
+ + +
+
+ ) +} 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/**'], + }, + }, + } })