diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..410b924b9 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +43,9 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Analytics (PostHog) bridge: renderer <-> main + ANALYTICS_IDENTIFY: 'analytics:identify', + ANALYTICS_RESET: 'analytics:reset', + ANALYTICS_CAPTURE: 'analytics:capture', + ANALYTICS_GET_CONTEXT: 'analytics:get-context', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index afb2ba038..63cce3d01 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -28,6 +28,13 @@ import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/activ import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; +import { + getDistinctId, + getMachineId, + identifyUser as analyticsIdentify, + resetUser as analyticsReset, + trackEvent, +} from '../modules/analytics'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -131,6 +138,41 @@ export function registerIpcHandlers(): void { if (config.generalAssist) await reregisterGeneralAssist(); if (config.quickAsk) await reregisterQuickAsk(); if (config.autocomplete) await reregisterAutocomplete(); + trackEvent('desktop_shortcut_updated', { + keys: Object.keys(config), + }); return updated; }); + + // Analytics bridge — the renderer (web UI) hands the logged-in user down + // to the main process so desktop-only events are attributed to the same + // PostHog person, not just an anonymous machine ID. + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_IDENTIFY, + (_event, payload: { userId: string; properties?: Record }) => { + if (!payload?.userId) return; + analyticsIdentify(String(payload.userId), payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => { + analyticsReset(); + }); + + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_CAPTURE, + (_event, payload: { event: string; properties?: Record }) => { + if (!payload?.event) return; + trackEvent(payload.event, payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => { + return { + distinctId: getDistinctId(), + machineId: getMachineId(), + appVersion: app.getVersion(), + platform: process.platform, + }; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 231553f9a..7556a2743 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -55,6 +55,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -71,6 +72,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isQuitting = true; + trackEvent('desktop_app_quit'); }); let didCleanup = false; diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts index 0bbcb3026..01dba60f0 100644 --- a/surfsense_desktop/src/modules/analytics.ts +++ b/surfsense_desktop/src/modules/analytics.ts @@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id'; import { app } from 'electron'; let client: PostHog | null = null; -let distinctId = ''; +let machineId = ''; +let currentDistinctId = ''; +let identifiedUserId: string | null = null; + +function baseProperties(): Record { + return { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + arch: process.arch, + machine_id: machineId, + }; +} export function initAnalytics(): void { const key = process.env.POSTHOG_KEY; if (!key) return; try { - distinctId = machineIdSync(true); + machineId = machineIdSync(true); + currentDistinctId = machineId; } catch { return; } @@ -22,17 +35,92 @@ export function initAnalytics(): void { }); } -export function trackEvent(event: string, properties?: Record): void { +export function getMachineId(): string { + return machineId; +} + +export function getDistinctId(): string { + return currentDistinctId; +} + +/** + * Identify the current logged-in user in PostHog so main-process desktop + * events (and linked anonymous machine events) are attributed to that person. + * + * Idempotent: calling identify repeatedly with the same userId is a no-op. + */ +export function identifyUser( + userId: string, + properties?: Record +): void { + if (!client || !userId) return; + if (identifiedUserId === userId) { + // Already identified — only refresh person properties + try { + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + }, + }); + } catch { + // ignore + } + return; + } + + try { + // Link the anonymous machine distinct ID to the authenticated user + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $anon_distinct_id: machineId, + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + $set_once: { + first_seen_platform: 'desktop', + }, + }, + }); + + identifiedUserId = userId; + currentDistinctId = userId; + } catch { + // Analytics must never break the app + } +} + +/** + * Reset user identity on logout. Subsequent events are captured anonymously + * against the machine ID until the user logs in again. + */ +export function resetUser(): void { + if (!client) return; + identifiedUserId = null; + currentDistinctId = machineId; +} + +export function trackEvent( + event: string, + properties?: Record +): void { if (!client) return; try { client.capture({ - distinctId, + distinctId: currentDistinctId || machineId, event, properties: { - platform: 'desktop', - app_version: app.getVersion(), - os: process.platform, + ...baseProperties(), ...properties, }, }); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index 47a85b730..e323abe53 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ import { app, dialog } from 'electron'; +import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; @@ -17,10 +18,18 @@ export function setupAutoUpdater(): void { autoUpdater.on('update-available', (info: { version: string }) => { console.log(`Update available: ${info.version}`); + trackEvent('desktop_update_available', { + current_version: version, + new_version: info.version, + }); }); autoUpdater.on('update-downloaded', (info: { version: string }) => { console.log(`Update downloaded: ${info.version}`); + trackEvent('desktop_update_downloaded', { + current_version: version, + new_version: info.version, + }); dialog.showMessageBox({ type: 'info', buttons: ['Restart', 'Later'], @@ -29,13 +38,19 @@ export function setupAutoUpdater(): void { message: `Version ${info.version} has been downloaded. Restart to apply the update.`, }).then(({ response }: { response: number }) => { if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_version: info.version }); } }); }); autoUpdater.on('error', (err: Error) => { console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + trackEvent('desktop_update_error', { + message: err.message?.split('\n')[0], + }); }); autoUpdater.checkForUpdates().catch(() => {}); diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 1a2b08395..bfd35bbaf 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; import { getServerPort } from './server'; +import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -16,6 +17,10 @@ function handleDeepLink(url: string) { if (!win) return; const parsed = new URL(url); + trackEvent('desktop_deep_link_received', { + host: parsed.hostname, + path: parsed.pathname, + }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts index 96b490d7b..ee4214d8a 100644 --- a/surfsense_desktop/src/modules/folder-watcher.ts +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'; import * as path from 'path'; import * as fs from 'fs'; import { IPC_CHANNELS } from '../ipc/channels'; +import { trackEvent } from './analytics'; export interface WatchedFolderConfig { path: string; @@ -401,6 +402,15 @@ export async function addWatchedFolder( await startWatcher(config); } + trackEvent('desktop_folder_watch_added', { + search_space_id: config.searchSpaceId, + root_folder_id: config.rootFolderId, + active: config.active, + has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0, + has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0, + is_update: existing >= 0, + }); + return folders; } @@ -409,6 +419,7 @@ export async function removeWatchedFolder( ): Promise { const s = await getStore(); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); s.set(STORE_KEY, updated); @@ -418,6 +429,13 @@ export async function removeWatchedFolder( const ms = await getMtimeStore(); ms.delete(folderPath); + if (removed) { + trackEvent('desktop_folder_watch_removed', { + search_space_id: removed.searchSpaceId, + root_folder_id: removed.rootFolderId, + }); + } + return updated; } diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 1749145a1..88444cc54 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { getMainWindow, createMainWindow } from './window'; import { getShortcuts } from './shortcuts'; +import { trackEvent } from './analytics'; let tray: Tray | null = null; let currentShortcut: string | null = null; @@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage { return img.resize({ width: 16, height: 16 }); } -function showMainWindow(): void { - let win = getMainWindow(); - if (!win || win.isDestroyed()) { - win = createMainWindow('/dashboard'); +function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void { + const existing = getMainWindow(); + const reopened = !existing || existing.isDestroyed(); + if (reopened) { + createMainWindow('/dashboard'); } else { - win.show(); - win.focus(); + existing.show(); + existing.focus(); } + trackEvent('desktop_main_window_shown', { source, reopened }); } function registerShortcut(accelerator: string): void { @@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void { } if (!accelerator) return; try { - const ok = globalShortcut.register(accelerator, showMainWindow); + const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut')); if (ok) { currentShortcut = accelerator; } else { @@ -50,13 +53,19 @@ export async function createTray(): Promise { tray.setToolTip('SurfSense'); const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: showMainWindow }, + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, { type: 'separator' }, - { label: 'Quit', click: () => { app.exit(0); } }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, ]); tray.setContextMenu(contextMenu); - tray.on('double-click', showMainWindow); + tray.on('double-click', () => showMainWindow('tray_click')); const shortcuts = await getShortcuts(); registerShortcut(shortcuts.generalAssist); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..01248e7d0 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -86,4 +86,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), + + // Analytics bridge — lets posthog-js running inside the Next.js renderer + // mirror identify/reset/capture into the Electron main-process PostHog + // client so desktop-only events are attributed to the logged-in user. + analyticsIdentify: (userId: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }), + analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET), + analyticsCapture: (event: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), + getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 9bc77edff..cb079db70 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ReceiptText } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; +import { Coins, FileText, ReceiptText } from "lucide-react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { @@ -12,10 +13,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import type { + PagePurchase, + PagePurchaseStatus, + TokenPurchase, +} from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; +type PurchaseKind = "pages" | "tokens"; + +type UnifiedPurchase = { + id: string; + kind: PurchaseKind; + created_at: string; + status: PagePurchaseStatus; + granted: number; + amount_total: number | null; + currency: string | null; +}; + const STATUS_STYLES: Record = { completed: { label: "Completed", @@ -31,6 +48,22 @@ const STATUS_STYLES: Record; iconClass: string } +> = { + pages: { + label: "Pages", + icon: FileText, + iconClass: "text-sky-500", + }, + tokens: { + label: "Premium Tokens", + icon: Coins, + iconClass: "text-amber-500", + }, +}; + function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -39,19 +72,65 @@ function formatDate(iso: string): string { }); } -function formatAmount(purchase: PagePurchase): string { - if (purchase.amount_total == null) return "—"; - const dollars = purchase.amount_total / 100; - const currency = (purchase.currency ?? "usd").toUpperCase(); - return `$${dollars.toFixed(2)} ${currency}`; +function formatAmount(amount: number | null, currency: string | null): string { + if (amount == null) return "—"; + const dollars = amount / 100; + const code = (currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${code}`; +} + +function normalizePagePurchase(p: PagePurchase): UnifiedPurchase { + return { + id: p.id, + kind: "pages", + created_at: p.created_at, + status: p.status, + granted: p.pages_granted, + amount_total: p.amount_total, + currency: p.currency, + }; +} + +function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { + return { + id: p.id, + kind: "tokens", + created_at: p.created_at, + status: p.status, + granted: p.tokens_granted, + amount_total: p.amount_total, + currency: p.currency, + }; } export function PurchaseHistoryContent() { - const { data, isLoading } = useQuery({ - queryKey: ["stripe-purchases"], - queryFn: () => stripeApiService.getPurchases(), + const results = useQueries({ + queries: [ + { + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }, + { + queryKey: ["stripe-token-purchases"], + queryFn: () => stripeApiService.getTokenPurchases(), + }, + ], }); + const [pagesQuery, tokensQuery] = results; + const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; + + const purchases = useMemo(() => { + const pagePurchases = pagesQuery.data?.purchases ?? []; + const tokenPurchases = tokensQuery.data?.purchases ?? []; + return [ + ...pagePurchases.map(normalizePagePurchase), + ...tokenPurchases.map(normalizeTokenPurchase), + ].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, [pagesQuery.data, tokensQuery.data]); + if (isLoading) { return (
@@ -60,15 +139,13 @@ export function PurchaseHistoryContent() { ); } - const purchases = data?.purchases ?? []; - if (purchases.length === 0) { return (

No purchases yet

- Your page-pack purchases will appear here after checkout. + Your page and premium token purchases will appear here after checkout.

); @@ -81,25 +158,36 @@ export function PurchaseHistoryContent() { Date - Pages + Type + Granted Amount Status {purchases.map((p) => { - const style = STATUS_STYLES[p.status]; + const statusStyle = STATUS_STYLES[p.status]; + const kind = KIND_META[p.kind]; + const KindIcon = kind.icon; return ( - + {formatDate(p.created_at)} - - {p.pages_granted.toLocaleString()} + +
+ + {kind.label} +
- {formatAmount(p)} + {p.granted.toLocaleString()} + + + {formatAmount(p.amount_total, p.currency)} - {style.label} + + {statusStyle.label} +
); @@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {

- Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. + Showing your {purchases.length} most recent purchase + {purchases.length !== 1 ? "s" : ""}.

); diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index da6885ffe..d430e0f6c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); +// ============================================================================ +// CONNECTOR TELEMETRY REGISTRY +// ---------------------------------------------------------------------------- +// Single source of truth for "what does this connector_type look like in +// analytics?". Any connector added to the lists above is automatically +// picked up here, so adding a new integration does NOT require touching +// `lib/posthog/events.ts` or per-connector tracking code. +// ============================================================================ + +export type ConnectorTelemetryGroup = + | "oauth" + | "composio" + | "crawler" + | "other" + | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = + (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; + })(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index caa85ba2d..7ac903342 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, + trackConnectorSetupFailure, + trackConnectorSetupStarted, trackIndexWithDateRangeOpened, trackIndexWithDateRangeStarted, trackPeriodicIndexingStarted, @@ -232,10 +234,20 @@ export const useConnectorDialog = () => { if (result.error) { const oauthConnector = result.connector - ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) + ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === result.connector) : null; const name = oauthConnector?.title || "connector"; + if (oauthConnector) { + trackConnectorSetupFailure( + Number(searchSpaceId), + oauthConnector.connectorType, + result.error, + "oauth_callback" + ); + } + if (result.error === "duplicate_account") { toast.error(`This ${name} account is already connected`, { description: "Please use a different account or manage the existing connection.", @@ -348,6 +360,12 @@ export const useConnectorDialog = () => { // Set connecting state immediately to disable button and show spinner setConnectingId(connector.id); + trackConnectorSetupStarted( + Number(searchSpaceId), + connector.connectorType, + "oauth_click" + ); + try { // Check if authEndpoint already has query parameters const separator = connector.authEndpoint.includes("?") ? "&" : "?"; @@ -369,6 +387,12 @@ export const useConnectorDialog = () => { window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connector.connectorType, + error instanceof Error ? error.message : "oauth_initiation_failed", + "oauth_init" + ); if (error instanceof Error && error.message.includes("Invalid auth URL")) { toast.error(`Invalid response from ${connector.title} OAuth endpoint`); } else { @@ -392,6 +416,11 @@ export const useConnectorDialog = () => { if (!searchSpaceId) return; setConnectingId("webcrawler-connector"); + trackConnectorSetupStarted( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + "webcrawler_quick_add" + ); try { await createConnector({ data: { @@ -441,6 +470,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating webcrawler connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + error instanceof Error ? error.message : "webcrawler_create_failed", + "webcrawler_quick_add" + ); toast.error("Failed to create web crawler connector"); } finally { setConnectingId(null); @@ -452,6 +487,12 @@ export const useConnectorDialog = () => { (connectorType: string) => { if (!searchSpaceId) return; + trackConnectorSetupStarted( + Number(searchSpaceId), + connectorType, + "non_oauth_click" + ); + // Handle Obsidian specifically on Desktop & Cloud if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { setIsOpen(false); @@ -680,6 +721,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connectingConnectorType ?? formData.connector_type, + error instanceof Error ? error.message : "connector_create_failed", + "non_oauth_form" + ); toast.error(error instanceof Error ? error.message : "Failed to create connector"); } finally { isCreatingConnectorRef.current = false; diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx index 1ac6baad4..b286c5316 100644 --- a/surfsense_web/components/free-chat/anonymous-chat.tsx +++ b/surfsense_web/components/free-chat/anonymous-chat.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; @@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) { textareaRef.current.style.height = "auto"; } + trackAnonymousChatMessageSent({ + modelSlug: model.seo_slug, + messageLength: trimmed.length, + surface: "free_model_page", + }); + const controller = new AbortController(); abortRef.current = controller; diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index b1d0f6850..b389a8489 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -28,6 +28,7 @@ import { updateToolCall, } from "@/lib/chat/streaming-state"; import { BACKEND_URL } from "@/lib/env-config"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; @@ -206,6 +207,14 @@ export function FreeChatPage() { } if (!userQuery.trim()) return; + trackAnonymousChatMessageSent({ + modelSlug, + messageLength: userQuery.trim().length, + hasUploadedDoc: + anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false, + surface: "free_chat_page", + }); + const userMsgId = `msg-user-${Date.now()}`; setMessages((prev) => [ ...prev, diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts index c4c6f2d74..c8b017044 100644 --- a/surfsense_web/contracts/types/stripe.types.ts +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({ premium_tokens_remaining: z.number().default(0), }); +export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; + export const tokenPurchase = z.object({ id: z.uuid(), stripe_checkout_session_id: z.string(), @@ -57,7 +59,7 @@ export const tokenPurchase = z.object({ tokens_granted: z.number(), amount_total: z.number().nullable(), currency: z.string().nullable(), - status: z.string(), + status: tokenPurchaseStatusEnum, completed_at: z.string().nullable(), created_at: z.string(), }); @@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer; export type CreateTokenCheckoutSessionRequest = z.infer; export type CreateTokenCheckoutSessionResponse = z.infer; export type TokenStripeStatusResponse = z.infer; +export type TokenPurchaseStatus = z.infer; export type TokenPurchase = z.infer; export type GetTokenPurchasesResponse = z.infer; diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index dff2e9bfe..3ae97fc0b 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,18 +1,65 @@ import posthog from "posthog-js"; -function initPostHog() { +/** + * PostHog initialisation for the Next.js renderer. + * + * The same bundle ships in two contexts: + * 1. A normal browser session on surfsense.com -> platform = "web" + * 2. The Electron desktop app (renders the Next app from localhost) + * -> platform = "desktop" + * + * When running inside Electron we also seed `posthog-js` with the main + * process's machine distinctId so that events fired from both the renderer + * (e.g. `chat_message_sent`, page views) and the Electron main process + * (e.g. `desktop_quick_ask_opened`) share a single PostHog person before + * login, and can be merged into the authenticated user afterwards. + */ + +function isElectron(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function currentPlatform(): "desktop" | "web" { + return isElectron() ? "desktop" : "web"; +} + +async function resolveBootstrapDistinctId(): Promise { + if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined; + try { + const ctx = await window.electronAPI.getAnalyticsContext(); + return ctx?.machineId || ctx?.distinctId || undefined; + } catch { + return undefined; + } +} + +async function initPostHog() { try { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + const platform = currentPlatform(); + const bootstrapDistinctId = await resolveBootstrapDistinctId(); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: "https://assets.surfsense.com", ui_host: "https://us.posthog.com", defaults: "2026-01-30", capture_pageview: "history_change", capture_pageleave: true, + ...(bootstrapDistinctId + ? { + bootstrap: { + distinctID: bootstrapDistinctId, + isIdentifiedID: false, + }, + } + : {}), before_send: (event) => { if (event?.properties) { - event.properties.platform = "web"; + event.properties.platform = platform; + if (platform === "desktop") { + event.properties.is_desktop = true; + } const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); @@ -30,9 +77,14 @@ function initPostHog() { event.properties.$set = { ...event.properties.$set, - platform: "web", + platform, last_seen_at: new Date().toISOString(), }; + + event.properties.$set_once = { + ...event.properties.$set_once, + first_seen_platform: platform, + }; } return event; }, @@ -51,8 +103,12 @@ if (typeof window !== "undefined") { window.posthog = posthog; if ("requestIdleCallback" in window) { - requestIdleCallback(initPostHog); + requestIdleCallback(() => { + void initPostHog(); + }); } else { - setTimeout(initPostHog, 3500); + setTimeout(() => { + void initPostHog(); + }, 3500); } } diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 53aaa71b9..34ed3044d 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; /** * PostHog Analytics Event Definitions @@ -13,8 +14,8 @@ import posthog from "posthog-js"; * - auth: Authentication events * - search_space: Search space management * - document: Document management - * - chat: Chat and messaging - * - connector: External connector events + * - chat: Chat and messaging (authenticated + anonymous) + * - connector: External connector events (all lifecycle stages) * - contact: Contact form events * - settings: Settings changes * - marketing: Marketing/referral tracking @@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record) { } } +/** + * Drop undefined values so PostHog doesn't log `"foo": undefined` noise. + */ +function compact>(obj: T): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + // ============================================ // AUTH EVENTS // ============================================ @@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st }); } +/** + * Track a message sent from the unauthenticated "free" / anonymous chat + * flow. This is intentionally a separate event from `chat_message_sent` + * so WAU / retention queries on the authenticated event stay clean while + * still giving us visibility into top-of-funnel usage on /free/*. + */ +export function trackAnonymousChatMessageSent(options: { + modelSlug: string; + messageLength?: number; + hasUploadedDoc?: boolean; + webSearchEnabled?: boolean; + surface?: "free_chat_page" | "free_model_page"; +}) { + safeCapture("anonymous_chat_message_sent", { + model_slug: options.modelSlug, + message_length: options.messageLength, + has_uploaded_doc: options.hasUploadedDoc ?? false, + web_search_enabled: options.webSearchEnabled, + surface: options.surface, + }); +} + // ============================================ // DOCUMENT EVENTS // ============================================ @@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { } // ============================================ -// CONNECTOR EVENTS +// CONNECTOR EVENTS (generic lifecycle dispatcher) // ============================================ +// +// All connector events go through `trackConnectorEvent`. The connector's +// human-readable title and its group (oauth/composio/crawler/other) are +// auto-attached from the shared registry in `connector-constants.ts`, so +// adding a new connector to that list is the only change required for it +// to show up correctly in PostHog dashboards. -export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - safeCapture("connector_setup_started", { - search_space_id: searchSpaceId, - connector_type: connectorType, +export type ConnectorEventStage = + | "setup_started" + | "setup_success" + | "setup_failure" + | "oauth_initiated" + | "connected" + | "deleted" + | "synced"; + +export interface ConnectorEventOptions { + searchSpaceId?: number | null; + connectorId?: number | null; + /** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */ + source?: string; + /** Free-form error message for failure events. */ + error?: string; + /** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */ + extra?: Record; +} + +/** + * Generic connector lifecycle tracker. Every connector analytics event + * should funnel through here so the enrichment stays consistent. + */ +export function trackConnectorEvent( + stage: ConnectorEventStage, + connectorType: string, + options: ConnectorEventOptions = {} +) { + const meta = getConnectorTelemetryMeta(connectorType); + safeCapture(`connector_${stage}`, { + ...compact({ + search_space_id: options.searchSpaceId ?? undefined, + connector_id: options.connectorId ?? undefined, + source: options.source, + error: options.error, + }), + connector_type: meta.connector_type, + connector_title: meta.connector_title, + connector_group: meta.connector_group, + is_oauth: meta.is_oauth, + ...(options.extra ?? {}), }); } +// ---- Convenience wrappers kept for backward compatibility ---- + +export function trackConnectorSetupStarted( + searchSpaceId: number, + connectorType: string, + source?: string +) { + trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source }); +} + export function trackConnectorSetupSuccess( searchSpaceId: number, connectorType: string, connectorId: number ) { - safeCapture("connector_setup_success", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSetupFailure( - searchSpaceId: number, + searchSpaceId: number | null | undefined, connectorType: string, - error?: string + error?: string, + source?: string ) { - safeCapture("connector_setup_failure", { - search_space_id: searchSpaceId, - connector_type: connectorType, + trackConnectorEvent("setup_failure", connectorType, { + searchSpaceId: searchSpaceId ?? undefined, error, + source, }); } @@ -218,11 +303,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - safeCapture("connector_deleted", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSynced( @@ -230,11 +311,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - safeCapture("connector_synced", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId }); } // ============================================ @@ -345,10 +422,9 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - safeCapture("connector_connected", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, + trackConnectorEvent("connected", connectorType, { + searchSpaceId, + connectorId: connectorId ?? undefined, }); } @@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) { // ============================================ /** - * Identify a user for PostHog analytics - * Call this after successful authentication + * Identify a user for PostHog analytics. + * Call this after successful authentication. + * + * In the Electron desktop app the same call is mirrored into the + * main-process PostHog client so desktop-only events (e.g. + * `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are + * attributed to the logged-in user rather than an anonymous machine ID. */ export function identifyUser(userId: string, properties?: Record) { try { @@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record Promise; setActiveSearchSpace: (id: string) => Promise; + // Analytics bridge (PostHog mirror into the Electron main process) + analyticsIdentify: (userId: string, properties?: Record) => Promise; + analyticsReset: () => Promise; + analyticsCapture: (event: string, properties?: Record) => Promise; + getAnalyticsContext: () => Promise<{ + distinctId: string; + machineId: string; + appVersion: string; + platform: string; + }>; } declare global {