From c96be7d9e1a68cc2061d21777bba55c36add18c1 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 13:25:13 -0800 Subject: [PATCH 1/6] feat: added posthog --- surfsense_web/.env.example | 5 + surfsense_web/app/(home)/register/page.tsx | 4 + .../new-chat/[[...chat_id]]/page.tsx | 12 +- surfsense_web/app/layout.tsx | 5 +- .../documents/document-mutation.atoms.ts | 19 +- .../atoms/invites/invites-mutation.atoms.ts | 15 +- .../new-llm-config-mutation.atoms.ts | 12 +- .../search-space-mutation.atoms.ts | 11 +- surfsense_web/components/TokenHandler.tsx | 5 + .../components/providers/PostHogProvider.tsx | 76 ++++++ .../components/sidebar/AppSidebarProvider.tsx | 2 +- .../components/sidebar/all-chats-sidebar.tsx | 2 +- .../components/tool-ui/generate-podcast.tsx | 21 +- .../hooks/use-search-source-connectors.ts | 33 +++ surfsense_web/lib/analytics.ts | 220 ++++++++++++++++++ surfsense_web/lib/chat/thread-persistence.ts | 41 +++- surfsense_web/package.json | 1 + surfsense_web/pnpm-lock.yaml | 41 ++++ 18 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 surfsense_web/components/providers/PostHogProvider.tsx create mode 100644 surfsense_web/lib/analytics.ts 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: From d9e6947fbd1b376052d1fe4536adf5252d07c873 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 13:37:35 -0800 Subject: [PATCH 2/6] Revert "feat: added posthog" This reverts commit c96be7d9e1a68cc2061d21777bba55c36add18c1. --- surfsense_web/.env.example | 5 - surfsense_web/app/(home)/register/page.tsx | 4 - .../new-chat/[[...chat_id]]/page.tsx | 12 +- surfsense_web/app/layout.tsx | 5 +- .../documents/document-mutation.atoms.ts | 19 +- .../atoms/invites/invites-mutation.atoms.ts | 15 +- .../new-llm-config-mutation.atoms.ts | 12 +- .../search-space-mutation.atoms.ts | 11 +- surfsense_web/components/TokenHandler.tsx | 5 - .../components/providers/PostHogProvider.tsx | 76 ------ .../components/sidebar/AppSidebarProvider.tsx | 2 +- .../components/sidebar/all-chats-sidebar.tsx | 2 +- .../components/tool-ui/generate-podcast.tsx | 21 +- .../hooks/use-search-source-connectors.ts | 33 --- surfsense_web/lib/analytics.ts | 220 ------------------ surfsense_web/lib/chat/thread-persistence.ts | 41 +--- surfsense_web/package.json | 1 - surfsense_web/pnpm-lock.yaml | 41 ---- 18 files changed, 19 insertions(+), 506 deletions(-) delete mode 100644 surfsense_web/components/providers/PostHogProvider.tsx delete mode 100644 surfsense_web/lib/analytics.ts diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index 88f3d7d3b..157bfaa37 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -1,10 +1,5 @@ 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 de83a5e57..c535832be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -9,7 +9,6 @@ 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"; @@ -65,9 +64,6 @@ 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 bc2d53132..f288bfa59 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,14 +359,10 @@ export default function NewChatPage() { }, ] : message.content; - appendMessage( - currentThreadId, - { - role: "user", - content: persistContent, - }, - searchSpaceId - ).catch((err) => console.error("Failed to persist user message:", err)); + appendMessage(currentThreadId, { + role: "user", + content: persistContent, + }).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 5b59cb86d..871293875 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -4,7 +4,6 @@ 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"; @@ -103,9 +102,7 @@ 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 c0c7e3493..0eae1081d 100644 --- a/surfsense_web/atoms/documents/document-mutation.atoms.ts +++ b/surfsense_web/atoms/documents/document-mutation.atoms.ts @@ -8,7 +8,6 @@ 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"; @@ -25,15 +24,7 @@ export const createDocumentMutationAtom = atomWithMutation((get) => { return documentsApiService.createDocument(request); }, - onSuccess: (data, request: CreateDocumentRequest) => { - // Track document creation/indexing - if (searchSpaceId) { - trackDocumentIndexed({ - search_space_id: Number(searchSpaceId), - document_type: request.document_type, - }); - } - + onSuccess: () => { toast.success("Document created successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.documents.globalQueryParams(documentsQueryParams), @@ -100,14 +91,6 @@ 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 07ad1360c..f2acb6e4a 100644 --- a/surfsense_web/atoms/invites/invites-mutation.atoms.ts +++ b/surfsense_web/atoms/invites/invites-mutation.atoms.ts @@ -6,7 +6,6 @@ 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"; @@ -19,12 +18,6 @@ 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()), }); @@ -81,13 +74,7 @@ export const acceptInviteMutationAtom = atomWithMutation(() => ({ mutationFn: async (request: AcceptInviteRequest) => { return invitesApiService.acceptInvite(request); }, - onSuccess: (data, variables) => { - // Track invite acceptance - trackInviteAccepted({ - search_space_id: data.search_space_id, - invite_code: variables.invite_code, - }); - + onSuccess: () => { 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 976d8f5b4..8f81b7475 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,7 +8,6 @@ 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"; @@ -26,16 +25,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => { mutationFn: async (request: CreateNewLLMConfigRequest) => { return newLLMConfigApiService.createConfig(request); }, - onSuccess: (_, request: CreateNewLLMConfigRequest) => { - // Track LLM config creation - if (searchSpaceId) { - trackLLMConfigCreated({ - search_space_id: Number(searchSpaceId), - provider: request.provider, - model_name: request.model_name, - }); - } - + onSuccess: () => { 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 2d3558df4..62f23507b 100644 --- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts @@ -5,7 +5,6 @@ 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"; @@ -18,10 +17,7 @@ export const createSearchSpaceMutationAtom = atomWithMutation(() => { return searchSpacesApiService.createSearchSpace(request); }, - onSuccess: (data, request: CreateSearchSpaceRequest) => { - // Track search space creation - trackSearchSpaceCreated({ search_space_id: data.id, name: request.name }); - + onSuccess: () => { toast.success("Search space created successfully"); queryClient.invalidateQueries({ queryKey: cacheKeys.searchSpaces.all, @@ -65,11 +61,6 @@ 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 3f595608f..70119dfe4 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -2,7 +2,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; -import { trackUserLoggedIn } from "@/lib/analytics"; import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; interface TokenHandlerProps { @@ -41,10 +40,6 @@ 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 deleted file mode 100644 index f5506f7f8..000000000 --- a/surfsense_web/components/providers/PostHogProvider.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"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 91c8aa487..5e7f08c4d 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, searchSpaceId); + await deleteThread(threadToDelete.id); // 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 eaffe53a0..9076715a3 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, searchSpaceId); + await deleteThread(threadId); 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 20140a769..166d95e47 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -2,11 +2,9 @@ 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"; @@ -289,9 +287,6 @@ 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(() => { @@ -322,20 +317,6 @@ 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); @@ -354,7 +335,7 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) clearInterval(pollingRef.current); } }; - }, [taskId, searchSpaceId]); + }, [taskId]); // 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 20011bbaa..2f77d7d82 100644 --- a/surfsense_web/hooks/use-search-source-connectors.ts +++ b/surfsense_web/hooks/use-search-source-connectors.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useState } from "react"; -import { trackConnectorAdded, trackConnectorDeleted, trackConnectorIndexed } from "@/lib/analytics"; import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils"; export interface SearchSourceConnector { @@ -192,14 +191,6 @@ 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); @@ -248,9 +239,6 @@ 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}`, { @@ -266,15 +254,6 @@ 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; @@ -318,9 +297,6 @@ 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 @@ -332,15 +308,6 @@ 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 deleted file mode 100644 index de0538eab..000000000 --- a/surfsense_web/lib/analytics.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 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 f7aa0c9b9..b84c5907b 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -3,7 +3,6 @@ * Provides API functions and thread list management. */ -import { trackChatCreated, trackChatDeleted, trackMessageSent } from "@/lib/analytics"; import { baseApiService } from "@/lib/apis/base-api.service"; // ============================================================================= @@ -81,18 +80,13 @@ export async function createThread( searchSpaceId: number, title = "New Chat" ): Promise { - const thread = await baseApiService.post("/api/v1/threads", undefined, { + return 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; } /** @@ -107,27 +101,11 @@ export async function getThreadMessages(threadId: number): Promise { - 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; + return baseApiService.post(`/api/v1/threads/${threadId}/messages`, undefined, { + body: message, + }); } /** @@ -145,13 +123,8 @@ export async function updateThread( /** * Delete a thread */ -export async function deleteThread(threadId: number, searchSpaceId?: number): Promise { +export async function deleteThread(threadId: number): Promise { await baseApiService.delete(`/api/v1/threads/${threadId}`); - - // Track chat deletion event - if (searchSpaceId) { - trackChatDeleted({ search_space_id: searchSpaceId, thread_id: threadId }); - } } // ============================================================================= @@ -245,7 +218,7 @@ export function createThreadListManager(config: ThreadListAdapterConfig) { async deleteThread(threadId: number): Promise { try { - await deleteThread(threadId, config.searchSpaceId); + await deleteThread(threadId); 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 8f55cda56..ed4c762a3 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -81,7 +81,6 @@ "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 f893b4b11..22a251e12 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -188,9 +188,6 @@ 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 @@ -1495,9 +1492,6 @@ 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==} @@ -3279,9 +3273,6 @@ 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==} @@ -3942,9 +3933,6 @@ 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'} @@ -5227,12 +5215,6 @@ 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'} @@ -6108,9 +6090,6 @@ 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'} @@ -7285,10 +7264,6 @@ 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': @@ -9135,8 +9110,6 @@ snapshots: confbox@0.1.8: {} - core-js@3.47.0: {} - cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9966,8 +9939,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fflate@0.4.8: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -11691,16 +11662,6 @@ 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: {} @@ -12806,8 +12767,6 @@ snapshots: web-namespaces@2.0.1: {} - web-vitals@4.2.4: {} - webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: From 518958e9a7c797f59ea23b00bf249ae7db77dc6e Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 13:53:41 -0800 Subject: [PATCH 3/6] feat: added posthog --- .../app/(home)/login/GoogleLoginButton.tsx | 6 + .../app/(home)/login/LocalLoginForm.tsx | 11 + surfsense_web/app/(home)/register/page.tsx | 16 + .../new-chat/[[...chat_id]]/page.tsx | 28 ++ .../[search_space_id]/settings/page.tsx | 8 +- .../[search_space_id]/sources/add/page.tsx | 8 + .../app/dashboard/searchspaces/page.tsx | 4 + surfsense_web/app/layout.tsx | 33 +- .../components/providers/PostHogProvider.tsx | 15 + .../components/sources/DocumentUploadTab.tsx | 18 +- surfsense_web/instrumentation-client.ts | 13 + surfsense_web/lib/posthog/events.ts | 291 ++++++++++++++++++ surfsense_web/lib/posthog/server.ts | 17 + surfsense_web/package.json | 3 + surfsense_web/pnpm-lock.yaml | 72 +++++ 15 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 surfsense_web/components/providers/PostHogProvider.tsx create mode 100644 surfsense_web/instrumentation-client.ts create mode 100644 surfsense_web/lib/posthog/events.ts create mode 100644 surfsense_web/lib/posthog/server.ts diff --git a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx index 6eef58aed..d4d9d4b4a 100644 --- a/surfsense_web/app/(home)/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -3,12 +3,16 @@ import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Logo } from "@/components/Logo"; +import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events"; import { AmbientBackground } from "./AmbientBackground"; export function GoogleLoginButton() { const t = useTranslations("auth"); const handleGoogleLogin = () => { + // Track Google login attempt + trackLoginAttempt("google"); + // Redirect to Google OAuth authorization URL // credentials: 'include' is required to accept the CSRF cookie from cross-origin response fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`, { @@ -24,10 +28,12 @@ export function GoogleLoginButton() { if (data.authorization_url) { window.location.href = data.authorization_url; } else { + trackLoginFailure("google", "No authorization URL received"); console.error("No authorization URL received"); } }) .catch((error) => { + trackLoginFailure("google", error?.message || "Unknown error"); console.error("Error during Google login:", error); }); }; diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 0157c9faf..44e9b27c2 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -10,6 +10,7 @@ import { toast } from "sonner"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { ValidationError } from "@/lib/error"; +import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; export function LocalLoginForm() { const t = useTranslations("auth"); @@ -37,6 +38,9 @@ export function LocalLoginForm() { e.preventDefault(); setError({ title: null, message: null }); // Clear any previous errors + // Track login attempt + trackLoginAttempt("local"); + // Show loading toast const loadingToast = toast.loading(tCommon("loading")); @@ -47,6 +51,9 @@ export function LocalLoginForm() { grant_type: "password", }); + // Track successful login + trackLoginSuccess("local"); + // Success toast toast.success(t("login_success"), { id: loadingToast, @@ -60,6 +67,7 @@ export function LocalLoginForm() { }, 500); } catch (err) { if (err instanceof ValidationError) { + trackLoginFailure("local", err.message); setError({ title: err.name, message: err.message }); toast.error(err.name, { id: loadingToast, @@ -78,6 +86,9 @@ export function LocalLoginForm() { errorCode = "NETWORK_ERROR"; } + // Track login failure + trackLoginFailure("local", errorCode); + // Get detailed error information from auth-errors utility const errorDetails = getAuthErrorDetails(errorCode); diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index c535832be..4a8dce546 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -11,6 +11,11 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AppError, ValidationError } from "@/lib/error"; +import { + trackRegistrationAttempt, + trackRegistrationFailure, + trackRegistrationSuccess, +} from "@/lib/posthog/events"; import { AmbientBackground } from "../login/AmbientBackground"; export default function RegisterPage() { @@ -52,6 +57,9 @@ export default function RegisterPage() { setError({ title: null, message: null }); // Clear any previous errors + // Track registration attempt + trackRegistrationAttempt(); + // Show loading toast const loadingToast = toast.loading(t("creating_account")); @@ -64,6 +72,9 @@ export default function RegisterPage() { is_verified: false, }); + // Track successful registration + trackRegistrationSuccess(); + // Success toast toast.success(t("register_success"), { id: loadingToast, @@ -81,6 +92,7 @@ export default function RegisterPage() { case 403: { const friendlyMessage = "Registrations are currently closed. If you need access, contact your administrator."; + trackRegistrationFailure("Registration disabled"); setError({ title: "Registration is disabled", message: friendlyMessage }); toast.error("Registration is disabled", { id: loadingToast, @@ -94,6 +106,7 @@ export default function RegisterPage() { } if (err instanceof ValidationError) { + trackRegistrationFailure(err.message); setError({ title: err.name, message: err.message }); toast.error(err.name, { id: loadingToast, @@ -113,6 +126,9 @@ export default function RegisterPage() { errorCode = "NETWORK_ERROR"; } + // Track registration failure + trackRegistrationFailure(errorCode); + // Get detailed error information from auth-errors utility const errorDetails = getAuthErrorDetails(errorCode); 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..dbea83fb7 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 @@ -37,6 +37,12 @@ import { getThreadMessages, type MessageRecord, } from "@/lib/chat/thread-persistence"; +import { + trackChatCreated, + trackChatError, + trackChatMessageSent, + trackChatResponseReceived, +} from "@/lib/posthog/events"; /** * Extract thinking steps from message content @@ -305,6 +311,10 @@ export default function NewChatPage() { const newThread = await createThread(searchSpaceId, "New Chat"); currentThreadId = newThread.id; setThreadId(currentThreadId); + + // Track chat creation + trackChatCreated(searchSpaceId, currentThreadId); + // Update URL silently using browser API (not router.replace) to avoid // interrupting the ongoing fetch/streaming with React navigation window.history.replaceState( @@ -331,6 +341,13 @@ export default function NewChatPage() { }; setMessages((prev) => [...prev, userMessage]); + // Track message sent + trackChatMessageSent(searchSpaceId, currentThreadId, { + hasAttachments: messageAttachments.length > 0, + hasMentionedDocuments: mentionedDocumentIds.length > 0, + messageLength: userQuery.length, + }); + // Store mentioned documents with this message for display if (mentionedDocuments.length > 0) { const docsInfo: MentionedDocumentInfo[] = mentionedDocuments.map((doc) => ({ @@ -653,6 +670,9 @@ export default function NewChatPage() { role: "assistant", content: finalContent, }).catch((err) => console.error("Failed to persist assistant message:", err)); + + // Track successful response + trackChatResponseReceived(searchSpaceId, currentThreadId); } } catch (error) { if (error instanceof Error && error.name === "AbortError") { @@ -660,6 +680,14 @@ export default function NewChatPage() { return; } console.error("[NewChatPage] Chat error:", error); + + // Track chat error + trackChatError( + searchSpaceId, + currentThreadId, + error instanceof Error ? error.message : "Unknown error" + ); + toast.error("Failed to get response. Please try again."); // Update assistant message with error setMessages((prev) => diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index ad96402a4..48efcd922 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -13,11 +13,12 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; import { Button } from "@/components/ui/button"; +import { trackSettingsViewed } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; interface SettingsNavItem { @@ -271,6 +272,11 @@ export default function SettingsPage() { const [activeSection, setActiveSection] = useState("models"); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + // Track settings section view + useEffect(() => { + trackSettingsViewed(searchSpaceId, activeSection); + }, [searchSpaceId, activeSection]); + const handleBackToApp = useCallback(() => { router.push(`/dashboard/${searchSpaceId}/new-chat`); }, [router, searchSpaceId]); diff --git a/surfsense_web/app/dashboard/[search_space_id]/sources/add/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/sources/add/page.tsx index 3c9b57f98..e0729e29b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/sources/add/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/sources/add/page.tsx @@ -9,6 +9,7 @@ import { ConnectorsTab } from "@/components/sources/ConnectorsTab"; import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab"; import { YouTubeTab } from "@/components/sources/YouTubeTab"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { trackSourcesTabViewed } from "@/lib/posthog/events"; export default function AddSourcesPage() { const params = useParams(); @@ -30,9 +31,16 @@ export default function AddSourcesPage() { router.push(`/dashboard/${search_space_id}/connectors/add/webcrawler-connector`); } else { setActiveTab(value); + // Track tab view + trackSourcesTabViewed(Number(search_space_id), value); } }; + // Track initial tab view + useEffect(() => { + trackSourcesTabViewed(Number(search_space_id), activeTab); + }, []); + return (
- - - - - {children} - - - - - + + + + + + {children} + + + + + + ); diff --git a/surfsense_web/components/providers/PostHogProvider.tsx b/surfsense_web/components/providers/PostHogProvider.tsx new file mode 100644 index 000000000..6f62afce7 --- /dev/null +++ b/surfsense_web/components/providers/PostHogProvider.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { PostHogProvider as PHProvider } from "@posthog/react"; +import posthog from "posthog-js"; +import type { ReactNode } from "react"; + +interface PostHogProviderProps { + children: ReactNode; +} + +export function PostHogProvider({ children }: PostHogProviderProps) { + // posthog-js is already initialized in instrumentation-client.ts + // We just need to wrap the app with the PostHogProvider for hook access + return {children}; +} diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index 7ad82d124..168db35cc 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -9,13 +9,17 @@ import { useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; - import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Separator } from "@/components/ui/separator"; +import { + trackDocumentUploadFailure, + trackDocumentUploadStarted, + trackDocumentUploadSuccess, +} from "@/lib/posthog/events"; import { GridPattern } from "./GridPattern"; interface DocumentUploadTabProps { @@ -154,6 +158,10 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) { const handleUpload = async () => { setUploadProgress(0); + // Track upload started + const totalSize = files.reduce((sum, file) => sum + file.size, 0); + trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalSize); + // Create a progress interval to simulate progress const progressInterval = setInterval(() => { setUploadProgress((prev) => { @@ -172,6 +180,10 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) { onSuccess: () => { clearInterval(progressInterval); setUploadProgress(100); + + // Track upload success + trackDocumentUploadSuccess(Number(searchSpaceId), files.length); + toast(t("upload_initiated"), { description: t("upload_initiated_desc"), }); @@ -180,6 +192,10 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) { onError: (error: any) => { clearInterval(progressInterval); setUploadProgress(0); + + // Track upload failure + trackDocumentUploadFailure(Number(searchSpaceId), error.message || "Upload failed"); + toast(t("upload_error"), { description: `${t("upload_error_desc")}: ${error.message || "Upload failed"}`, }); diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts new file mode 100644 index 000000000..0eec5eabe --- /dev/null +++ b/surfsense_web/instrumentation-client.ts @@ -0,0 +1,13 @@ +import posthog from "posthog-js"; + +if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + defaults: "2025-11-30", + // Disable automatic pageview capture, as we capture manually with PostHogProvider + // This ensures proper pageview tracking with Next.js client-side navigation + capture_pageview: "history_change", + // Enable session recording + capture_pageleave: true, + }); +} diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts new file mode 100644 index 000000000..fae713f80 --- /dev/null +++ b/surfsense_web/lib/posthog/events.ts @@ -0,0 +1,291 @@ +import posthog from "posthog-js"; + +/** + * PostHog Analytics Event Definitions + * + * This file defines all custom analytics events tracked in SurfSense. + * Events follow a consistent naming convention: category_action + * + * Categories: + * - auth: Authentication events + * - search_space: Search space management + * - document: Document management + * - chat: Chat and messaging + * - connector: External connector events + * - contact: Contact form events + * - settings: Settings changes + */ + +// ============================================ +// AUTH EVENTS +// ============================================ + +export function trackLoginAttempt(method: "local" | "google") { + posthog.capture("auth_login_attempt", { + method, + }); +} + +export function trackLoginSuccess(method: "local" | "google") { + posthog.capture("auth_login_success", { + method, + }); +} + +export function trackLoginFailure(method: "local" | "google", error?: string) { + posthog.capture("auth_login_failure", { + method, + error, + }); +} + +export function trackRegistrationAttempt() { + posthog.capture("auth_registration_attempt"); +} + +export function trackRegistrationSuccess() { + posthog.capture("auth_registration_success"); +} + +export function trackRegistrationFailure(error?: string) { + posthog.capture("auth_registration_failure", { + error, + }); +} + +export function trackLogout() { + posthog.capture("auth_logout"); +} + +// ============================================ +// SEARCH SPACE EVENTS +// ============================================ + +export function trackSearchSpaceCreated(searchSpaceId: number, name: string) { + posthog.capture("search_space_created", { + search_space_id: searchSpaceId, + name, + }); +} + +export function trackSearchSpaceDeleted(searchSpaceId: number) { + posthog.capture("search_space_deleted", { + search_space_id: searchSpaceId, + }); +} + +export function trackSearchSpaceViewed(searchSpaceId: number) { + posthog.capture("search_space_viewed", { + search_space_id: searchSpaceId, + }); +} + +// ============================================ +// CHAT EVENTS +// ============================================ + +export function trackChatCreated(searchSpaceId: number, chatId: number) { + posthog.capture("chat_created", { + search_space_id: searchSpaceId, + chat_id: chatId, + }); +} + +export function trackChatMessageSent( + searchSpaceId: number, + chatId: number, + options?: { + hasAttachments?: boolean; + hasMentionedDocuments?: boolean; + messageLength?: number; + } +) { + posthog.capture("chat_message_sent", { + search_space_id: searchSpaceId, + chat_id: chatId, + has_attachments: options?.hasAttachments ?? false, + has_mentioned_documents: options?.hasMentionedDocuments ?? false, + message_length: options?.messageLength, + }); +} + +export function trackChatResponseReceived(searchSpaceId: number, chatId: number) { + posthog.capture("chat_response_received", { + search_space_id: searchSpaceId, + chat_id: chatId, + }); +} + +export function trackChatError(searchSpaceId: number, chatId: number, error?: string) { + posthog.capture("chat_error", { + search_space_id: searchSpaceId, + chat_id: chatId, + error, + }); +} + +// ============================================ +// DOCUMENT EVENTS +// ============================================ + +export function trackDocumentUploadStarted( + searchSpaceId: number, + fileCount: number, + totalSizeBytes: number +) { + posthog.capture("document_upload_started", { + search_space_id: searchSpaceId, + file_count: fileCount, + total_size_bytes: totalSizeBytes, + }); +} + +export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) { + posthog.capture("document_upload_success", { + search_space_id: searchSpaceId, + file_count: fileCount, + }); +} + +export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) { + posthog.capture("document_upload_failure", { + search_space_id: searchSpaceId, + error, + }); +} + +export function trackDocumentDeleted(searchSpaceId: number, documentId: number) { + posthog.capture("document_deleted", { + search_space_id: searchSpaceId, + document_id: documentId, + }); +} + +export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) { + posthog.capture("document_bulk_deleted", { + search_space_id: searchSpaceId, + count, + }); +} + +export function trackYouTubeImport(searchSpaceId: number, url: string) { + posthog.capture("youtube_import_started", { + search_space_id: searchSpaceId, + url, + }); +} + +// ============================================ +// CONNECTOR EVENTS +// ============================================ + +export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { + posthog.capture("connector_setup_started", { + search_space_id: searchSpaceId, + connector_type: connectorType, + }); +} + +export function trackConnectorSetupSuccess( + searchSpaceId: number, + connectorType: string, + connectorId: number +) { + posthog.capture("connector_setup_success", { + search_space_id: searchSpaceId, + connector_type: connectorType, + connector_id: connectorId, + }); +} + +export function trackConnectorSetupFailure( + searchSpaceId: number, + connectorType: string, + error?: string +) { + posthog.capture("connector_setup_failure", { + search_space_id: searchSpaceId, + connector_type: connectorType, + error, + }); +} + +export function trackConnectorDeleted( + searchSpaceId: number, + connectorType: string, + connectorId: number +) { + posthog.capture("connector_deleted", { + search_space_id: searchSpaceId, + connector_type: connectorType, + connector_id: connectorId, + }); +} + +export function trackConnectorSynced( + searchSpaceId: number, + connectorType: string, + connectorId: number +) { + posthog.capture("connector_synced", { + search_space_id: searchSpaceId, + connector_type: connectorType, + connector_id: connectorId, + }); +} + +// ============================================ +// SETTINGS EVENTS +// ============================================ + +export function trackSettingsViewed(searchSpaceId: number, section: string) { + posthog.capture("settings_viewed", { + search_space_id: searchSpaceId, + section, + }); +} + +export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) { + posthog.capture("settings_updated", { + search_space_id: searchSpaceId, + section, + setting, + }); +} + +// ============================================ +// FEATURE USAGE EVENTS +// ============================================ + +export function trackPodcastGenerated(searchSpaceId: number, chatId: number) { + posthog.capture("podcast_generated", { + search_space_id: searchSpaceId, + chat_id: chatId, + }); +} + +export function trackSourcesTabViewed(searchSpaceId: number, tab: string) { + posthog.capture("sources_tab_viewed", { + search_space_id: searchSpaceId, + tab, + }); +} + +// ============================================ +// USER IDENTIFICATION +// ============================================ + +/** + * Identify a user for PostHog analytics + * Call this after successful authentication + */ +export function identifyUser(userId: string, properties?: Record) { + posthog.identify(userId, properties); +} + +/** + * Reset user identity (call on logout) + */ +export function resetUser() { + posthog.reset(); +} diff --git a/surfsense_web/lib/posthog/server.ts b/surfsense_web/lib/posthog/server.ts new file mode 100644 index 000000000..5bfd79ca7 --- /dev/null +++ b/surfsense_web/lib/posthog/server.ts @@ -0,0 +1,17 @@ +import { PostHog } from "posthog-node"; + +export default function PostHogClient() { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { + throw new Error("NEXT_PUBLIC_POSTHOG_KEY is not set"); + } + + const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + // Because server-side functions in Next.js can be short-lived, + // we set flushAt to 1 and flushInterval to 0 to ensure events are sent immediately + flushAt: 1, + flushInterval: 0, + }); + + return posthogClient; +} diff --git a/surfsense_web/package.json b/surfsense_web/package.json index ed4c762a3..5d602c2ab 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -32,6 +32,7 @@ "@hookform/resolvers": "^5.2.2", "@next/third-parties": "^16.1.0", "@number-flow/react": "^0.5.10", + "@posthog/react": "^1.5.2", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", @@ -81,6 +82,8 @@ "next-themes": "^0.4.6", "pg": "^8.16.3", "postgres": "^3.4.7", + "posthog-js": "^1.310.1", + "posthog-node": "^5.18.0", "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..a94b63c0d 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@number-flow/react': specifier: ^0.5.10 version: 0.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@posthog/react': + specifier: ^1.5.2 + version: 1.5.2(@types/react@19.2.7)(posthog-js@1.310.1)(react@19.2.3) '@radix-ui/react-accordion': specifier: ^1.2.11 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -188,6 +191,12 @@ importers: postgres: specifier: ^3.4.7 version: 3.4.7 + posthog-js: + specifier: ^1.310.1 + version: 1.310.1 + posthog-node: + specifier: ^5.18.0 + version: 5.18.0 react: specifier: ^19.2.3 version: 19.2.3 @@ -1492,6 +1501,19 @@ 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==} + + '@posthog/react@1.5.2': + resolution: {integrity: sha512-KHdXbV1yba7Y2l8BVmwXlySWxqKVLNQ5ZiVvWOf7r3Eo7GIFxCM4CaNK/z83kKWn8KTskmKy7AGF6Hl6INWK3g==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3273,6 +3295,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 +3958,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 +5243,16 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + posthog-js@1.310.1: + resolution: {integrity: sha512-UkR6zzlWNtqHDXHJl2Yk062DOmZyVKTPL5mX4j4V+u3RiYbMHJe47+PpMMUsvK1R2e1r/m9uSlHaJMJRzyUjGg==} + + posthog-node@5.18.0: + resolution: {integrity: sha512-SLBEs+sCThxzTGSSDEe97nZHuFFYh6DupObR1yQdvQND3CJh0ogZ0Sa1Vb+Tbrnf0cWbfBC9XNkm44yhaWf3aA==} + engines: {node: '>=20'} + + 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 +6128,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 +7305,17 @@ 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 + + '@posthog/react@1.5.2(@types/react@19.2.7)(posthog-js@1.310.1)(react@19.2.3)': + dependencies: + posthog-js: 1.310.1 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.7 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.0.0': @@ -9110,6 +9162,8 @@ snapshots: confbox@0.1.8: {} + core-js@3.47.0: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9939,6 +9993,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 +11718,20 @@ 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 + + posthog-node@5.18.0: + dependencies: + '@posthog/core': 1.9.0 + + preact@10.28.1: {} + prelude-ls@1.2.1: {} prismjs@1.27.0: {} @@ -12767,6 +12837,8 @@ snapshots: web-namespaces@2.0.1: {} + web-vitals@4.2.4: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: From 8298efe0b1bbbcefd2d7b72af214f013a529de11 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 14:22:48 -0800 Subject: [PATCH 4/6] feat: configure PostHog reverse proxy to bypass ad blockers - Updated PostHog initialization to use a reverse proxy for API requests. - Added rewrites in Next.js configuration to route requests through the domain. - Set ui_host for proper toolbar functionality and ensured correct pageview tracking. --- surfsense_web/instrumentation-client.ts | 5 ++++- surfsense_web/next.config.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index 0eec5eabe..15f989bb4 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -2,7 +2,10 @@ import posthog from "posthog-js"; if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + // Use reverse proxy to bypass ad blockers + api_host: "/ingest", + // Required for toolbar and other UI features to work correctly + ui_host: "https://us.posthog.com", defaults: "2025-11-30", // Disable automatic pageview capture, as we capture manually with PostHogProvider // This ensures proper pageview tracking with Next.js client-side navigation diff --git a/surfsense_web/next.config.ts b/surfsense_web/next.config.ts index 1f232b1e1..f7491b4d3 100644 --- a/surfsense_web/next.config.ts +++ b/surfsense_web/next.config.ts @@ -31,6 +31,27 @@ const nextConfig: NextConfig = { } return config; }, + + // PostHog reverse proxy configuration + // This helps bypass ad blockers by routing requests through your domain + async rewrites() { + return [ + { + source: "/ingest/static/:path*", + destination: "https://us-assets.i.posthog.com/static/:path*", + }, + { + source: "/ingest/:path*", + destination: "https://us.i.posthog.com/:path*", + }, + { + source: "/ingest/decide", + destination: "https://us.i.posthog.com/decide", + }, + ]; + }, + // Required for PostHog reverse proxy to work correctly + skipTrailingSlashRedirect: true, }; // Wrap the config with MDX and next-intl plugins From ea000dc22cbb49c396bb924af51e5d41324a4179 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 21:18:32 -0800 Subject: [PATCH 5/6] feat: update README.zh-CN.md with new features and tools - Added details about the deep proxy architecture and built-in tools. - Included instructions for contributors to add new tools. - Updated external data source list to include BookStack. - Enhanced descriptions of existing features and frameworks. --- README.zh-CN.md | 73 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index cd64467c8..5572dcaf5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ # SurfSense -虽然像 NotebookLM 和 Perplexity 这样的工具在对任何主题/查询进行研究时令人印象深刻且非常有效,但 SurfSense 通过与您的个人知识库集成,将这一能力提升到了新的高度。它是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Slack、Linear、Jira、ClickUp、Confluence、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Elasticsearch 等,未来还会支持更多。 +虽然像 NotebookLM 和 Perplexity 这样的工具在对任何主题/查询进行研究时令人印象深刻且非常有效,但 SurfSense 通过与您的个人知识库集成,将这一能力提升到了新的高度。它是一个高度可定制的 AI 研究助手,可以连接外部数据源,如搜索引擎(SearxNG、Tavily、LinkUp)、Slack、Linear、Jira、ClickUp、Confluence、BookStack、Gmail、Notion、YouTube、GitHub、Discord、Airtable、Google Calendar、Luma、Elasticsearch 等,未来还会支持更多。
MODSetter%2FSurfSense | Trendshift @@ -71,6 +71,27 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7 - 支持本地 TTS 提供商(Kokoro TTS) - 支持多个 TTS 提供商(OpenAI、Azure、Google Vertex AI) +### 🤖 **深度代理架构** + +#### 内置代理工具 +| 工具 | 描述 | +|------|------| +| **search_knowledge_base** | 使用语义+全文混合搜索、日期过滤和连接器特定查询搜索您的个人知识库 | +| **generate_podcast** | 从聊天对话或知识库内容生成音频播客 | +| **link_preview** | 获取 URL 的 Open Graph 元数据以显示预览卡片 | +| **display_image** | 在聊天中显示带有元数据和来源归属的图像 | +| **scrape_webpage** | 从网页中提取完整内容用于分析和总结(支持 Firecrawl 或本地 Chromium/Trafilatura) | + +#### 可扩展工具注册表 +贡献者可以通过注册表模式轻松添加新工具: +1. 在 `surfsense_backend/app/agents/new_chat/tools/` 中创建工具工厂函数 +2. 在 `registry.py` 的 `BUILTIN_TOOLS` 列表中注册 + +#### 可配置的系统提示词 +- 通过 LLM 配置自定义系统指令 +- 按配置切换引用开关 +- 通过 LiteLLM 集成支持 100+ 种 LLM + ### 📊 **先进的 RAG 技术** - 支持 100+ 种大语言模型 - 支持 6000+ 种嵌入模型 @@ -86,6 +107,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7 - Jira - ClickUp - Confluence +- BookStack - Notion - Gmail - YouTube 视频 @@ -257,11 +279,13 @@ Docker 和手动安装指南都包含适用于 Windows、macOS 和 Linux 的详 - **FastAPI Users**:使用 JWT 和 OAuth 支持的身份验证和用户管理 -- **LangGraph**:用于开发 AI 代理的框架 - +- **深度代理**:基于 LangGraph 构建的自定义代理框架,用于推理和行动的 AI 代理,支持可配置工具 + +- **LangGraph**:用于开发具有对话持久性的有状态 AI 代理的框架 + - **LangChain**:用于开发 AI 驱动应用程序的框架 -- **LLM 集成**:通过 LiteLLM 与大语言模型集成 +- **LiteLLM**:通用 LLM 集成,支持 100+ 种模型(OpenAI、Anthropic、Ollama 等) - **Rerankers**:先进的结果排序,提高搜索相关性 @@ -285,33 +309,19 @@ Docker 和手动安装指南都包含适用于 Windows、macOS 和 Linux 的详 --- ### **前端** -- **Next.js 15.2.3**:React 框架,具有应用路由器、服务器组件、自动代码拆分和优化渲染功能 +- **Next.js**:React 框架,具有应用路由器、服务器组件、自动代码拆分和优化渲染功能 -- **React 19.0.0**:用于构建用户界面的 JavaScript 库 +- **React**:用于构建用户界面的 JavaScript 库 - **TypeScript**:JavaScript 的静态类型检查,提升代码质量和开发体验 - **Vercel AI SDK Kit UI Stream Protocol**:创建可扩展的聊天 UI -- **Tailwind CSS 4.x**:实用优先的 CSS 框架,用于构建自定义 UI 设计 +- **Tailwind CSS**:实用优先的 CSS 框架,用于构建自定义 UI 设计 - **Shadcn**:无头组件库 -- **Lucide React**:作为 React 组件实现的图标集 - -- **Framer Motion**:React 动画库 - -- **Sonner**:Toast 通知库 - -- **Geist**:Vercel 的字体系列 - -- **React Hook Form**:表单状态管理和验证 - -- **Zod**:TypeScript 优先的模式验证,带静态类型推断 - -- **@hookform/resolvers**:用于在 React Hook Form 中使用验证库的解析器 - -- **@tanstack/react-table**:用于构建强大表格和数据网格的无头 UI +- **Motion(Framer Motion)**:React 动画库 ### **DevOps** @@ -332,6 +342,25 @@ Docker 和手动安装指南都包含适用于 Windows、macOS 和 Linux 的详 非常欢迎贡献!贡献可以小到一个 ⭐,甚至是发现和创建问题。 后端的微调总是受欢迎的。 +### 添加新的代理工具 + +想要为 SurfSense 代理添加新工具?非常简单: + +1. 在 `surfsense_backend/app/agents/new_chat/tools/my_tool.py` 中创建您的工具文件 +2. 在 `registry.py` 中注册: + +```python +ToolDefinition( + name="my_tool", + description="What my tool does", + factory=lambda deps: create_my_tool( + search_space_id=deps["search_space_id"], + db_session=deps["db_session"], + ), + requires=["search_space_id", "db_session"], +), +``` + 有关详细的贡献指南,请参阅我们的 [CONTRIBUTING.md](CONTRIBUTING.md) 文件。 ## Star 历史 From 2f35bc644fc43d54d2e59e6e692c8e04e5b18f0a Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 25 Dec 2025 21:20:01 -0800 Subject: [PATCH 6/6] chore: update README.zh-CN.md to replace video link and remove outdated screenshots --- README.zh-CN.md | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 5572dcaf5..def665fa9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,8 +25,7 @@ # 视频演示 - -https://github.com/user-attachments/assets/d9221908-e0de-4b2f-ac3a-691cf4b202da +https://github.com/user-attachments/assets/42a29ea1-d4d8-4213-9c69-972b5b806d58 ## 播客示例 @@ -236,32 +235,6 @@ Docker 和手动安装指南都包含适用于 Windows、macOS 和 Linux 的详 - LlamaIndex API 密钥(增强解析,支持 50+ 种格式) - 其他根据用例需要的 API 密钥 -## 截图 - -**研究助手** - -![updated_researcher](https://github.com/user-attachments/assets/e22c5d86-f511-4c72-8c50-feba0c1561b4) - -**搜索空间** - -![search_spaces](https://github.com/user-attachments/assets/e254c38c-f937-44b6-9e9d-770db583d099) - -**管理文档** -![documents](https://github.com/user-attachments/assets/7001e306-eb06-4009-89c6-8fadfdc3fc4d) - -**播客助手** -![podcasts](https://github.com/user-attachments/assets/6cb82ffd-9e14-4172-bc79-67faf34c4c1c) - - -**对话助手** - -![git_chat](https://github.com/user-attachments/assets/bb352d52-1c6d-4020-926b-722d0b98b491) - -**浏览器扩展** - -![ext1](https://github.com/user-attachments/assets/1f042b7a-6349-422b-94fb-d40d0df16c40) - -![ext2](https://github.com/user-attachments/assets/a9b9f1aa-2677-404d-b0a0-c1b2dddf24a7) ## 技术栈