diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index 157bfaa37..88f3d7d3b 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -1,5 +1,10 @@ NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING + +# Enable Posthog - OPTIONAL +NEXT_PUBLIC_POSTHOG_KEY=phc_XcAf95z8Vl +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com + # Contact Form Vars - OPTIONAL DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres \ No newline at end of file diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index c535832be..de83a5e57 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; +import { trackUserSignedUp } from "@/lib/analytics"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AppError, ValidationError } from "@/lib/error"; import { AmbientBackground } from "../login/AmbientBackground"; @@ -64,6 +65,9 @@ export default function RegisterPage() { is_verified: false, }); + // Track successful registration + trackUserSignedUp({ method: "email" }); + // Success toast toast.success(t("register_success"), { id: loadingToast, diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index f288bfa59..bc2d53132 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -359,10 +359,14 @@ export default function NewChatPage() { }, ] : message.content; - appendMessage(currentThreadId, { - role: "user", - content: persistContent, - }).catch((err) => console.error("Failed to persist user message:", err)); + appendMessage( + currentThreadId, + { + role: "user", + content: persistContent, + }, + searchSpaceId + ).catch((err) => console.error("Failed to persist user message:", err)); // Start streaming response setIsRunning(true); diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 871293875..5b59cb86d 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -4,6 +4,7 @@ import { GoogleAnalytics } from "@next/third-parties/google"; import { RootProvider } from "fumadocs-ui/provider/next"; import { Roboto } from "next/font/google"; import { I18nProvider } from "@/components/providers/I18nProvider"; +import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { ThemeProvider } from "@/components/theme/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { LocaleProvider } from "@/contexts/LocaleContext"; @@ -102,7 +103,9 @@ export default function RootLayout({ defaultTheme="light" > - {children} + + {children} + diff --git a/surfsense_web/atoms/documents/document-mutation.atoms.ts b/surfsense_web/atoms/documents/document-mutation.atoms.ts index 0eae1081d..c0c7e3493 100644 --- a/surfsense_web/atoms/documents/document-mutation.atoms.ts +++ b/surfsense_web/atoms/documents/document-mutation.atoms.ts @@ -8,6 +8,7 @@ import type { UpdateDocumentRequest, UploadDocumentRequest, } from "@/contracts/types/document.types"; +import { trackDocumentDeleted, trackDocumentIndexed } from "@/lib/analytics"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; @@ -24,7 +25,15 @@ export const createDocumentMutationAtom = atomWithMutation((get) => { return documentsApiService.createDocument(request); }, - onSuccess: () => { + onSuccess: (data, request: CreateDocumentRequest) => { + // Track document creation/indexing + if (searchSpaceId) { + trackDocumentIndexed({ + search_space_id: Number(searchSpaceId), + document_type: request.document_type, + }); + } + toast.success("Document created successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.documents.globalQueryParams(documentsQueryParams), @@ -91,6 +100,14 @@ export const deleteDocumentMutationAtom = atomWithMutation((get) => { }, onSuccess: (_, request: DeleteDocumentRequest) => { + // Track document deletion + if (searchSpaceId) { + trackDocumentDeleted({ + search_space_id: Number(searchSpaceId), + document_id: request.id, + }); + } + toast.success("Document deleted successfully"); queryClient.setQueryData( cacheKeys.documents.globalQueryParams(documentsQueryParams), diff --git a/surfsense_web/atoms/invites/invites-mutation.atoms.ts b/surfsense_web/atoms/invites/invites-mutation.atoms.ts index f2acb6e4a..07ad1360c 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -6,6 +6,7 @@ import type { DeleteInviteRequest, UpdateInviteRequest, } from "@/contracts/types/invites.types"; +import { trackInviteAccepted, trackInviteCreated } from "@/lib/analytics"; import { invitesApiService } from "@/lib/apis/invites-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; @@ -18,6 +19,12 @@ export const createInviteMutationAtom = atomWithMutation(() => ({ return invitesApiService.createInvite(request); }, onSuccess: (_, variables) => { + // Track invite creation + trackInviteCreated({ + search_space_id: variables.search_space_id, + role_name: variables.data.role_id ? `role_${variables.data.role_id}` : undefined, + }); + queryClient.invalidateQueries({ queryKey: cacheKeys.invites.all(variables.search_space_id.toString()), }); @@ -74,7 +81,13 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({ mutationFn: async (request: AcceptInviteRequest) => { return invitesApiService.acceptInvite(request); }, - onSuccess: () => { + onSuccess: (data, variables) => { + // Track invite acceptance + trackInviteAccepted({ + search_space_id: data.search_space_id, + invite_code: variables.invite_code, + }); + queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all }); toast.success("Invite accepted successfully"); }, diff --git a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts index 8f81b7475..976d8f5b4 100644 --- a/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts +++ b/surfsense_web/atoms/new-llm-config/new-llm-config-mutation.atoms.ts @@ -8,6 +8,7 @@ import type { UpdateNewLLMConfigRequest, UpdateNewLLMConfigResponse, } from "@/contracts/types/new-llm-config.types"; +import { trackLLMConfigCreated } from "@/lib/analytics"; import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; @@ -25,7 +26,16 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => { mutationFn: async (request: CreateNewLLMConfigRequest) => { return newLLMConfigApiService.createConfig(request); }, - onSuccess: () => { + onSuccess: (_, request: CreateNewLLMConfigRequest) => { + // Track LLM config creation + if (searchSpaceId) { + trackLLMConfigCreated({ + search_space_id: Number(searchSpaceId), + provider: request.provider, + model_name: request.model_name, + }); + } + toast.success("Configuration created successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)), diff --git a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts index 62f23507b..2d3558df4 100644 --- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts @@ -5,6 +5,7 @@ import type { DeleteSearchSpaceRequest, UpdateSearchSpaceRequest, } from "@/contracts/types/search-space.types"; +import { trackSearchSpaceCreated, trackSearchSpaceDeleted } from "@/lib/analytics"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; @@ -17,7 +18,10 @@ export const createSearchSpaceMutationAtom = atomWithMutation(() => { return searchSpacesApiService.createSearchSpace(request); }, - onSuccess: () => { + onSuccess: (data, request: CreateSearchSpaceRequest) => { + // Track search space creation + trackSearchSpaceCreated({ search_space_id: data.id, name: request.name }); + toast.success("Search space created successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all, @@ -61,6 +65,11 @@ export const deleteSearchSpaceMutationAtom = atomWithMutation((get) => { }, onSuccess: (_, request: DeleteSearchSpaceRequest) => { + // Track search space deletion + if (request.id) { + trackSearchSpaceDeleted({ search_space_id: request.id }); + } + toast.success("Search space deleted successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all, diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 70119dfe4..3f595608f 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -2,6 +2,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; +import { trackUserLoggedIn } from "@/lib/analytics"; import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; interface TokenHandlerProps { @@ -40,6 +41,10 @@ const TokenHandler = ({ localStorage.setItem(storageKey, token); setBearerToken(token); + // Track successful login (works for both email and Google OAuth) + const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; + trackUserLoggedIn({ method: authType === "GOOGLE" ? "google" : "email" }); + // Check if there's a saved redirect path from before the auth flow const savedRedirectPath = getAndClearRedirectPath(); diff --git a/surfsense_web/components/providers/PostHogProvider.tsx b/surfsense_web/components/providers/PostHogProvider.tsx new file mode 100644 index 000000000..f5506f7f8 --- /dev/null +++ b/surfsense_web/components/providers/PostHogProvider.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"; +import { useEffect, useRef } from "react"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; + +// Initialize PostHog only on client side +if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", + person_profiles: "identified_only", + capture_pageview: true, + capture_pageleave: true, + autocapture: false, // We'll use manual event tracking for better control + persistence: "localStorage", + loaded: (posthog) => { + if (process.env.NODE_ENV === "development") { + // Uncomment to debug in development + // posthog.debug(); + } + }, + }); +} + +/** + * Component that handles user identification with PostHog + * Placed inside the provider hierarchy to access user data + */ +function PostHogUserIdentifier() { + const ph = usePostHog(); + const { data: user, isSuccess } = useAtomValue(currentUserAtom); + const hasIdentified = useRef(false); + + useEffect(() => { + if (isSuccess && user && !hasIdentified.current) { + // Identify the user with PostHog + ph.identify(user.id, { + email: user.email, + is_active: user.is_active, + is_superuser: user.is_superuser, + is_verified: user.is_verified, + }); + hasIdentified.current = true; + } + }, [ph, user, isSuccess]); + + // Reset identification flag when user logs out (user becomes null) + useEffect(() => { + if (!user && hasIdentified.current) { + ph.reset(); + hasIdentified.current = false; + } + }, [ph, user]); + + return null; +} + +/** + * PostHog Analytics Provider + * Wraps the app to enable analytics tracking and user identification + */ +export function PostHogProvider({ children }: { children: React.ReactNode }) { + // Don't render provider if PostHog key is not configured + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { + return <>{children}; + } + + return ( + + + {children} + + ); +} diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 5e7f08c4d..91c8aa487 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -146,7 +146,7 @@ export function AppSidebarProvider({ setIsDeletingThread(true); try { - await deleteThread(threadToDelete.id); + await deleteThread(threadToDelete.id, searchSpaceId); // Invalidate threads query to refresh the list queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); } catch (error) { diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx index 9076715a3..eaffe53a0 100644 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -121,7 +121,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS async (threadId: number) => { setDeletingThreadId(threadId); try { - await deleteThread(threadId); + await deleteThread(threadId, searchSpaceId); toast.success(t("chat_deleted") || "Chat deleted successfully"); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 166d95e47..20140a769 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -2,9 +2,11 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { Audio } from "@/components/tool-ui/audio"; +import { trackPodcastGenerated } from "@/lib/analytics"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state"; @@ -287,6 +289,9 @@ function PodcastPlayer({ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) { const [taskStatus, setTaskStatus] = useState({ status: "processing" }); const pollingRef = useRef(null); + const hasTrackedRef = useRef(false); + const params = useParams(); + const searchSpaceId = params.search_space_id ? Number(params.search_space_id) : undefined; // Set active podcast state when this component mounts useEffect(() => { @@ -317,6 +322,20 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) } // Clear the active podcast state when task completes clearActivePodcastTaskId(); + + // Track successful podcast generation (only once) + if ( + response.status === "success" && + response.podcast_id && + searchSpaceId && + !hasTrackedRef.current + ) { + hasTrackedRef.current = true; + trackPodcastGenerated({ + search_space_id: searchSpaceId, + podcast_id: response.podcast_id, + }); + } } } catch (err) { console.error("Error polling task status:", err); @@ -335,7 +354,7 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) clearInterval(pollingRef.current); } }; - }, [taskId]); + }, [taskId, searchSpaceId]); // Show loading state while processing if (taskStatus.status === "processing") { diff --git a/surfsense_web/hooks/use-search-source-connectors.ts b/surfsense_web/hooks/use-search-source-connectors.ts index 2f77d7d82..20011bbaa 100644 --- a/surfsense_web/hooks/use-search-source-connectors.ts +++ b/surfsense_web/hooks/use-search-source-connectors.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { trackConnectorAdded, trackConnectorDeleted, trackConnectorIndexed } from "@/lib/analytics"; import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils"; export interface SearchSourceConnector { @@ -191,6 +192,14 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: const updatedConnectors = [...connectors, newConnector]; setConnectors(updatedConnectors); updateConnectorSourceItems(updatedConnectors); + + // Track connector creation + trackConnectorAdded({ + search_space_id: spaceId, + connector_type: connectorData.connector_type, + connector_id: newConnector.id, + }); + return newConnector; } catch (err) { console.error("Error creating search source connector:", err); @@ -239,6 +248,9 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: */ const deleteConnector = async (connectorId: number) => { try { + // Find connector before deleting to get its type for tracking + const connectorToDelete = connectors.find((c) => c.id === connectorId); + const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, { @@ -254,6 +266,15 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: const updatedConnectors = connectors.filter((connector) => connector.id !== connectorId); setConnectors(updatedConnectors); updateConnectorSourceItems(updatedConnectors); + + // Track connector deletion + if (connectorToDelete) { + trackConnectorDeleted({ + search_space_id: connectorToDelete.search_space_id, + connector_type: connectorToDelete.connector_type, + connector_id: connectorId, + }); + } } catch (err) { console.error("Error deleting search source connector:", err); throw err; @@ -297,6 +318,9 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: const result = await response.json(); + // Find the connector to get its type for tracking + const indexedConnector = connectors.find((c) => c.id === connectorId); + // Update the connector's last_indexed_at timestamp const updatedConnectors = connectors.map((connector) => connector.id === connectorId @@ -308,6 +332,15 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?: ); setConnectors(updatedConnectors); + // Track connector indexing + if (indexedConnector) { + trackConnectorIndexed({ + search_space_id: Number(searchSpaceId), + connector_type: indexedConnector.connector_type, + connector_id: connectorId, + }); + } + return result; } catch (err) { console.error("Error indexing connector content:", err); diff --git a/surfsense_web/lib/analytics.ts b/surfsense_web/lib/analytics.ts new file mode 100644 index 000000000..de0538eab --- /dev/null +++ b/surfsense_web/lib/analytics.ts @@ -0,0 +1,220 @@ +/** + * PostHog Analytics Utility + * Provides typed event tracking functions for SurfSense + */ + +import posthog from "posthog-js"; + +// Check if PostHog is initialized +function isPostHogReady(): boolean { + return typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_POSTHOG_KEY; +} + +// ============================================================================= +// Event Names (Constants for consistency) +// ============================================================================= + +export const ANALYTICS_EVENTS = { + // Authentication + USER_SIGNED_UP: "user_signed_up", + USER_LOGGED_IN: "user_logged_in", + USER_LOGGED_OUT: "user_logged_out", + + // Search Spaces + SEARCH_SPACE_CREATED: "search_space_created", + SEARCH_SPACE_DELETED: "search_space_deleted", + + // Chat + CHAT_CREATED: "chat_created", + MESSAGE_SENT: "message_sent", + CHAT_DELETED: "chat_deleted", + + // Documents + DOCUMENT_INDEXED: "document_indexed", + DOCUMENT_DELETED: "document_deleted", + + // Connectors + CONNECTOR_ADDED: "connector_added", + CONNECTOR_DELETED: "connector_deleted", + CONNECTOR_INDEXED: "connector_indexed", + + // Podcasts + PODCAST_GENERATED: "podcast_generated", + PODCAST_DELETED: "podcast_deleted", + + // LLM Config + LLM_CONFIG_CREATED: "llm_config_created", + + // Invites + INVITE_CREATED: "invite_created", + INVITE_ACCEPTED: "invite_accepted", +} as const; + +// ============================================================================= +// Authentication Events +// ============================================================================= + +export function trackUserSignedUp(properties?: { method?: "email" | "google" }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.USER_SIGNED_UP, properties); +} + +export function trackUserLoggedIn(properties?: { method?: "email" | "google" }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.USER_LOGGED_IN, properties); +} + +export function trackUserLoggedOut() { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.USER_LOGGED_OUT); +} + +// ============================================================================= +// Search Space Events +// ============================================================================= + +export function trackSearchSpaceCreated(properties: { search_space_id: number; name: string }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.SEARCH_SPACE_CREATED, properties); +} + +export function trackSearchSpaceDeleted(properties: { search_space_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.SEARCH_SPACE_DELETED, properties); +} + +// ============================================================================= +// Chat Events +// ============================================================================= + +export function trackChatCreated(properties: { search_space_id: number; thread_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.CHAT_CREATED, properties); +} + +export function trackMessageSent(properties: { + search_space_id: number; + thread_id: number; + role: "user" | "assistant" | "system"; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.MESSAGE_SENT, properties); +} + +export function trackChatDeleted(properties: { search_space_id: number; thread_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.CHAT_DELETED, properties); +} + +// ============================================================================= +// Document Events +// ============================================================================= + +export function trackDocumentIndexed(properties: { + search_space_id: number; + document_type: string; + count?: number; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.DOCUMENT_INDEXED, { + ...properties, + count: properties.count ?? 1, + }); +} + +export function trackDocumentDeleted(properties: { search_space_id: number; document_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.DOCUMENT_DELETED, properties); +} + +// ============================================================================= +// Connector Events +// ============================================================================= + +export function trackConnectorAdded(properties: { + search_space_id: number; + connector_type: string; + connector_id?: number; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.CONNECTOR_ADDED, properties); +} + +export function trackConnectorDeleted(properties: { + search_space_id: number; + connector_type: string; + connector_id: number; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.CONNECTOR_DELETED, properties); +} + +export function trackConnectorIndexed(properties: { + search_space_id: number; + connector_type: string; + connector_id: number; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.CONNECTOR_INDEXED, properties); +} + +// ============================================================================= +// Podcast Events +// ============================================================================= + +export function trackPodcastGenerated(properties: { search_space_id: number; podcast_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.PODCAST_GENERATED, properties); +} + +export function trackPodcastDeleted(properties: { search_space_id: number; podcast_id: number }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.PODCAST_DELETED, properties); +} + +// ============================================================================= +// LLM Config Events +// ============================================================================= + +export function trackLLMConfigCreated(properties: { + search_space_id: number; + provider: string; + model_name: string; +}) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.LLM_CONFIG_CREATED, properties); +} + +// ============================================================================= +// Invite Events +// ============================================================================= + +export function trackInviteCreated(properties: { search_space_id: number; role_name?: string }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.INVITE_CREATED, properties); +} + +export function trackInviteAccepted(properties: { search_space_id: number; invite_code: string }) { + if (!isPostHogReady()) return; + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, properties); +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Generic event capture for custom events + */ +export function trackEvent(eventName: string, properties?: Record) { + if (!isPostHogReady()) return; + posthog.capture(eventName, properties); +} + +/** + * Reset PostHog user identification (call on logout) + */ +export function resetAnalytics() { + if (!isPostHogReady()) return; + posthog.reset(); +} diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index b84c5907b..f7aa0c9b9 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -3,6 +3,7 @@ * Provides API functions and thread list management. */ +import { trackChatCreated, trackChatDeleted, trackMessageSent } from "@/lib/analytics"; import { baseApiService } from "@/lib/apis/base-api.service"; // ============================================================================= @@ -80,13 +81,18 @@ export async function createThread( searchSpaceId: number, title = "New Chat" ): Promise { - return baseApiService.post("/api/v1/threads", undefined, { + const thread = await baseApiService.post("/api/v1/threads", undefined, { body: { title, archived: false, search_space_id: searchSpaceId, }, }); + + // Track chat creation event + trackChatCreated({ search_space_id: searchSpaceId, thread_id: thread.id }); + + return thread; } /** @@ -101,11 +107,27 @@ export async function getThreadMessages(threadId: number): Promise { - return baseApiService.post(`/api/v1/threads/${threadId}/messages`, undefined, { - body: message, - }); + const result = await baseApiService.post( + `/api/v1/threads/${threadId}/messages`, + undefined, + { + body: message, + } + ); + + // Track message sent event (only for user messages to avoid double-counting) + if (message.role === "user" && searchSpaceId) { + trackMessageSent({ + search_space_id: searchSpaceId, + thread_id: threadId, + role: message.role, + }); + } + + return result; } /** @@ -123,8 +145,13 @@ export async function updateThread( /** * Delete a thread */ -export async function deleteThread(threadId: number): Promise { +export async function deleteThread(threadId: number, searchSpaceId?: number): Promise { await baseApiService.delete(`/api/v1/threads/${threadId}`); + + // Track chat deletion event + if (searchSpaceId) { + trackChatDeleted({ search_space_id: searchSpaceId, thread_id: threadId }); + } } // ============================================================================= @@ -218,7 +245,7 @@ export function createThreadListManager(config: ThreadListAdapterConfig) { async deleteThread(threadId: number): Promise { try { - await deleteThread(threadId); + await deleteThread(threadId, config.searchSpaceId); return true; } catch (error) { console.error("[ThreadListManager] Failed to delete thread:", error); diff --git a/surfsense_web/package.json b/surfsense_web/package.json index ed4c762a3..8f55cda56 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -81,6 +81,7 @@ "next-themes": "^0.4.6", "pg": "^8.16.3", "postgres": "^3.4.7", + "posthog-js": "^1.310.1", "react": "^19.2.3", "react-day-picker": "^9.8.1", "react-dom": "^19.2.3", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 22a251e12..f893b4b11 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: postgres: specifier: ^3.4.7 version: 3.4.7 + posthog-js: + specifier: ^1.310.1 + version: 1.310.1 react: specifier: ^19.2.3 version: 19.2.3 @@ -1492,6 +1495,9 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@posthog/core@1.9.0': + resolution: {integrity: sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3273,6 +3279,9 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3933,6 +3942,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -5215,6 +5227,12 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + posthog-js@1.310.1: + resolution: {integrity: sha512-UkR6zzlWNtqHDXHJl2Yk062DOmZyVKTPL5mX4j4V+u3RiYbMHJe47+PpMMUsvK1R2e1r/m9uSlHaJMJRzyUjGg==} + + preact@10.28.1: + resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6090,6 +6108,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -7264,6 +7285,10 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@posthog/core@1.9.0': + dependencies: + cross-spawn: 7.0.6 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.0.0': @@ -9110,6 +9135,8 @@ snapshots: confbox@0.1.8: {} + core-js@3.47.0: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9939,6 +9966,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -11662,6 +11691,16 @@ snapshots: postgres@3.4.7: {} + posthog-js@1.310.1: + dependencies: + '@posthog/core': 1.9.0 + core-js: 3.47.0 + fflate: 0.4.8 + preact: 10.28.1 + web-vitals: 4.2.4 + + preact@10.28.1: {} + prelude-ls@1.2.1: {} prismjs@1.27.0: {} @@ -12767,6 +12806,8 @@ snapshots: web-namespaces@2.0.1: {} + web-vitals@4.2.4: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: