mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: implement analytics tracking for desktop app events
- Added event tracking for desktop app activation and quitting. - Introduced analytics bridge in preload script to handle user identification and event capturing. - Updated IPC channels to support analytics-related actions. - Enhanced analytics functionality in the main process to track user interactions and application updates. - Integrated analytics tracking for folder watching and deep link handling. - Improved connector setup tracking in the web application. This commit enhances the overall analytics capabilities of the application, ensuring better user behavior insights and event tracking across both desktop and web environments.
This commit is contained in:
parent
b38a297349
commit
b440610e04
18 changed files with 673 additions and 80 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): void {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.capture({
|
||||
distinctId,
|
||||
distinctId: currentDistinctId || machineId,
|
||||
event,
|
||||
properties: {
|
||||
platform: 'desktop',
|
||||
app_version: app.getVersion(),
|
||||
os: process.platform,
|
||||
...baseProperties(),
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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<WatchedFolderConfig[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }),
|
||||
analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET),
|
||||
analyticsCapture: (event: string, properties?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<PagePurchaseStatus, { label: string; className: string }> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
|
|
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
|
|||
},
|
||||
};
|
||||
|
||||
const KIND_META: Record<
|
||||
PurchaseKind,
|
||||
{ label: string; icon: React.ComponentType<{ className?: string }>; 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<UnifiedPurchase[]>(() => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
|
@ -60,15 +139,13 @@ export function PurchaseHistoryContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const purchases = data?.purchases ?? [];
|
||||
|
||||
if (purchases.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page-pack purchases will appear here after checkout.
|
||||
Your page and premium token purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -81,25 +158,36 @@ export function PurchaseHistoryContent() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Pages</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Granted</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={p.id}>
|
||||
<TableRow key={`${p.kind}-${p.id}`}>
|
||||
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.pages_granted.toLocaleString()}
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
|
||||
<span>{kind.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p)}
|
||||
{p.granted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge>
|
||||
<Badge className={cn("text-[10px]", statusStyle.className)}>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {
|
|||
</Table>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
|
||||
Showing your {purchases.length} most recent purchase
|
||||
{purchases.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
|
|||
|
||||
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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<string, ConnectorTelemetryMeta> =
|
||||
(() => {
|
||||
const map = new Map<string, ConnectorTelemetryMeta>();
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof getPagePurchasesResponse>;
|
|||
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
|
||||
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
|
||||
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
|
||||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
|
||||
*/
|
||||
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>) {
|
||||
try {
|
||||
|
|
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
|
||||
void window.electronAPI.analyticsIdentify(userId, properties);
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user identity (call on logout)
|
||||
* Reset user identity (call on logout). Mirrors the reset into the
|
||||
* Electron main process when running inside the desktop app.
|
||||
*/
|
||||
export function resetUser() {
|
||||
try {
|
||||
|
|
@ -487,4 +577,12 @@ export function resetUser() {
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
|
||||
void window.electronAPI.analyticsReset();
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
surfsense_web/types/window.d.ts
vendored
10
surfsense_web/types/window.d.ts
vendored
|
|
@ -105,6 +105,16 @@ interface ElectronAPI {
|
|||
// Active search space
|
||||
getActiveSearchSpace: () => Promise<string | null>;
|
||||
setActiveSearchSpace: (id: string) => Promise<void>;
|
||||
// Analytics bridge (PostHog mirror into the Electron main process)
|
||||
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
analyticsReset: () => Promise<void>;
|
||||
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
getAnalyticsContext: () => Promise<{
|
||||
distinctId: string;
|
||||
machineId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue