diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 4d217562a..62ba5d445 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -71,6 +71,8 @@ jobs: working-directory: surfsense_desktop env: HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} - name: Package & Publish run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish always -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} diff --git a/surfsense_desktop/.env b/surfsense_desktop/.env index d053aac97..a0463a39d 100644 --- a/surfsense_desktop/.env +++ b/surfsense_desktop/.env @@ -4,3 +4,7 @@ # The hosted web frontend URL. Used to intercept OAuth redirects and keep them # inside the desktop app. Set to your production frontend domain. HOSTED_FRONTEND_URL=https://surfsense.net + +# PostHog analytics (leave empty to disable) +POSTHOG_KEY= +POSTHOG_HOST=https://us.i.posthog.com diff --git a/surfsense_desktop/scripts/build-electron.mjs b/surfsense_desktop/scripts/build-electron.mjs index 9f507ea37..bfce6a9ad 100644 --- a/surfsense_desktop/scripts/build-electron.mjs +++ b/surfsense_desktop/scripts/build-electron.mjs @@ -111,6 +111,12 @@ async function buildElectron() { 'process.env.HOSTED_FRONTEND_URL': JSON.stringify( process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.net' ), + 'process.env.POSTHOG_KEY': JSON.stringify( + process.env.POSTHOG_KEY || desktopEnv.POSTHOG_KEY || '' + ), + 'process.env.POSTHOG_HOST': JSON.stringify( + process.env.POSTHOG_HOST || desktopEnv.POSTHOG_HOST || 'https://us.i.posthog.com' + ), }, }; diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 95b0359c8..231553f9a 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -12,6 +12,7 @@ import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomp import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher'; import { registerIpcHandlers } from './ipc/handlers'; import { createTray, destroyTray } from './modules/tray'; +import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; registerGlobalErrorHandlers(); @@ -22,6 +23,8 @@ if (!setupDeepLinks()) { registerIpcHandlers(); app.whenReady().then(async () => { + initAnalytics(); + trackEvent('desktop_app_launched'); setupMenu(); try { await startNextServer(); @@ -70,9 +73,15 @@ app.on('before-quit', () => { isQuitting = true; }); -app.on('will-quit', () => { +let didCleanup = false; +app.on('will-quit', async (e) => { + if (didCleanup) return; + didCleanup = true; + e.preventDefault(); unregisterQuickAsk(); unregisterAutocomplete(); unregisterFolderWatcher(); destroyTray(); + await shutdownAnalytics(); + app.exit(); }); diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts new file mode 100644 index 000000000..8f64c1bd8 --- /dev/null +++ b/surfsense_desktop/src/modules/analytics.ts @@ -0,0 +1,46 @@ +import { PostHog } from 'posthog-node'; +import { machineIdSync } from 'node-machine-id'; +import { app } from 'electron'; + +let client: PostHog | null = null; +let distinctId = ''; + +export function initAnalytics(): void { + const key = process.env.POSTHOG_KEY; + if (!key) return; + + try { + distinctId = machineIdSync(true); + } catch { + return; + } + + client = new PostHog(key, { + host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com', + flushAt: 20, + flushInterval: 10000, + }); +} + +export function trackEvent(event: string, properties?: Record): void { + if (!client) return; + + client.capture({ + distinctId, + event, + properties: { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + ...properties, + }, + }); +} + +export async function shutdownAnalytics(): Promise { + if (!client) return; + + const timeout = new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.race([client.shutdown(), timeout]); + client = null; +} diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index cb09a42e1..d4eb727fd 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -6,6 +6,7 @@ import { captureScreen } from './screenshot'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; import { getShortcuts } from '../shortcuts'; import { getActiveSearchSpaceId } from '../active-search-space'; +import { trackEvent } from '../analytics'; let currentShortcut = ''; let autocompleteEnabled = true; @@ -41,6 +42,7 @@ async function triggerAutocomplete(): Promise { console.warn('[autocomplete] No active search space. Select a search space first.'); return; } + trackEvent('desktop_autocomplete_triggered', { search_space_id: searchSpaceId }); const cursor = screen.getCursorScreenPoint(); const win = createSuggestionWindow(cursor.x, cursor.y); @@ -87,9 +89,11 @@ function registerIpcHandlers(): void { ipcRegistered = true; ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => { + trackEvent('desktop_autocomplete_accepted'); await acceptAndInject(text); }); ipcMain.handle(IPC_CHANNELS.DISMISS_SUGGESTION, () => { + trackEvent('desktop_autocomplete_dismissed'); destroySuggestion(); }); ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => { diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index d5a2a9c2e..d700b421a 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -5,6 +5,7 @@ import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePa import { getServerPort } from './server'; import { getShortcuts } from './shortcuts'; import { getActiveSearchSpaceId } from './active-search-space'; +import { trackEvent } from './analytics'; let currentShortcut = ''; let quickAskWindow: BrowserWindow | null = null; @@ -120,6 +121,7 @@ async function quickAskHandler(): Promise { sourceApp = getFrontmostApp(); console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Assist with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)'); + trackEvent('desktop_quick_ask_opened', { has_selected_text: !!selected }); openQuickAsk(text); } @@ -151,6 +153,7 @@ function registerIpcHandlers(): void { if (!checkAccessibilityPermission()) return; + trackEvent('desktop_quick_ask_replaced'); clipboard.writeText(text); destroyQuickAsk();