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: