diff --git a/surfsense_web/components/providers/PostHogProvider.tsx b/surfsense_web/components/providers/PostHogProvider.tsx
index 1216730f3..906d552c2 100644
--- a/surfsense_web/components/providers/PostHogProvider.tsx
+++ b/surfsense_web/components/providers/PostHogProvider.tsx
@@ -5,17 +5,17 @@ import posthog from "posthog-js";
import type { ReactNode } from "react";
import "../../instrumentation-client";
import { PostHogIdentify } from "./PostHogIdentify";
+import { PostHogReferral } from "./PostHogReferral";
interface PostHogProviderProps {
children: ReactNode;
}
export function PostHogProvider({ children }: PostHogProviderProps) {
- // posthog-js is initialized by importing instrumentation-client.ts above
- // We wrap the app with the PostHogProvider for hook access
return (
+
{children}
);
diff --git a/surfsense_web/components/providers/PostHogReferral.tsx b/surfsense_web/components/providers/PostHogReferral.tsx
new file mode 100644
index 000000000..8db27ac95
--- /dev/null
+++ b/surfsense_web/components/providers/PostHogReferral.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect } from "react";
+import { trackReferralLanding } from "@/lib/posthog/events";
+
+const REF_STORAGE_KEY = "surfsense_ref_code";
+
+/**
+ * Captures the ?ref= URL parameter on first landing and fires a
+ * PostHog event so marketing campaigns can be attributed.
+ *
+ * The ref code is persisted to sessionStorage so it survives client-side
+ * navigations that strip query params (e.g. login redirect), but a fresh
+ * event is fired for each new browser session with a ref param.
+ */
+export function PostHogReferral() {
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const params = new URLSearchParams(window.location.search);
+ const ref = params.get("ref");
+
+ if (ref) {
+ try {
+ sessionStorage.setItem(REF_STORAGE_KEY, ref);
+ } catch {
+ // Private browsing may block sessionStorage
+ }
+ trackReferralLanding(ref, window.location.href);
+ }
+ }, []);
+
+ return null;
+}
diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts
index c6c6320b0..2c5e0a2c2 100644
--- a/surfsense_web/instrumentation-client.ts
+++ b/surfsense_web/instrumentation-client.ts
@@ -1,29 +1,47 @@
import posthog from "posthog-js";
function initPostHog() {
- if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
+ try {
+ if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
- posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
- api_host: "/ingest",
- ui_host: "https://us.posthog.com",
- defaults: "2025-11-30",
- capture_pageview: "history_change",
- capture_pageleave: true,
- before_send: (event) => {
- if (event.properties) {
- event.properties.$set = {
- ...event.properties.$set,
- last_seen_at: new Date().toISOString(),
- };
- }
- return event;
- },
- loaded: (ph) => {
- if (typeof window !== "undefined") {
- window.posthog = ph;
- }
- },
- });
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
+ api_host: "/ingest",
+ ui_host: "https://us.posthog.com",
+ defaults: "2025-11-30",
+ capture_pageview: "history_change",
+ capture_pageleave: true,
+ before_send: (event) => {
+ if (event.properties) {
+ const params = new URLSearchParams(window.location.search);
+ const ref = params.get("ref");
+ if (ref) {
+ event.properties.ref_code = ref;
+ event.properties.$set = {
+ ...event.properties.$set,
+ initial_ref_code: ref,
+ };
+ event.properties.$set_once = {
+ ...event.properties.$set_once,
+ first_ref_code: ref,
+ };
+ }
+
+ event.properties.$set = {
+ ...event.properties.$set,
+ last_seen_at: new Date().toISOString(),
+ };
+ }
+ return event;
+ },
+ loaded: (ph) => {
+ if (typeof window !== "undefined") {
+ window.posthog = ph;
+ }
+ },
+ });
+ } catch {
+ // PostHog init failed (likely ad-blocker) – app must continue to work
+ }
}
if (typeof window !== "undefined") {
diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts
index aa1d1e8c5..53aaa71b9 100644
--- a/surfsense_web/lib/posthog/events.ts
+++ b/surfsense_web/lib/posthog/events.ts
@@ -3,7 +3,10 @@ import posthog from "posthog-js";
/**
* PostHog Analytics Event Definitions
*
- * This file defines all custom analytics events tracked in SurfSense.
+ * All capture/identify/reset calls are wrapped in try-catch so that
+ * ad-blockers that interfere with posthog-js can never break app
+ * functionality (e.g. the chat flow).
+ *
* Events follow a consistent naming convention: category_action
*
* Categories:
@@ -14,47 +17,47 @@ import posthog from "posthog-js";
* - connector: External connector events
* - contact: Contact form events
* - settings: Settings changes
+ * - marketing: Marketing/referral tracking
*/
+function safeCapture(event: string, properties?: Record) {
+ try {
+ posthog.capture(event, properties);
+ } catch {
+ // Silently ignore – analytics should never break the app
+ }
+}
+
// ============================================
// AUTH EVENTS
// ============================================
export function trackLoginAttempt(method: "local" | "google") {
- posthog.capture("auth_login_attempt", {
- method,
- });
+ safeCapture("auth_login_attempt", { method });
}
export function trackLoginSuccess(method: "local" | "google") {
- posthog.capture("auth_login_success", {
- method,
- });
+ safeCapture("auth_login_success", { method });
}
export function trackLoginFailure(method: "local" | "google", error?: string) {
- posthog.capture("auth_login_failure", {
- method,
- error,
- });
+ safeCapture("auth_login_failure", { method, error });
}
export function trackRegistrationAttempt() {
- posthog.capture("auth_registration_attempt");
+ safeCapture("auth_registration_attempt");
}
export function trackRegistrationSuccess() {
- posthog.capture("auth_registration_success");
+ safeCapture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) {
- posthog.capture("auth_registration_failure", {
- error,
- });
+ safeCapture("auth_registration_failure", { error });
}
export function trackLogout() {
- posthog.capture("auth_logout");
+ safeCapture("auth_logout");
}
// ============================================
@@ -62,20 +65,20 @@ export function trackLogout() {
// ============================================
export function trackSearchSpaceCreated(searchSpaceId: number, name: string) {
- posthog.capture("search_space_created", {
+ safeCapture("search_space_created", {
search_space_id: searchSpaceId,
name,
});
}
export function trackSearchSpaceDeleted(searchSpaceId: number) {
- posthog.capture("search_space_deleted", {
+ safeCapture("search_space_deleted", {
search_space_id: searchSpaceId,
});
}
export function trackSearchSpaceViewed(searchSpaceId: number) {
- posthog.capture("search_space_viewed", {
+ safeCapture("search_space_viewed", {
search_space_id: searchSpaceId,
});
}
@@ -85,7 +88,7 @@ export function trackSearchSpaceViewed(searchSpaceId: number) {
// ============================================
export function trackChatCreated(searchSpaceId: number, chatId: number) {
- posthog.capture("chat_created", {
+ safeCapture("chat_created", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
@@ -100,7 +103,7 @@ export function trackChatMessageSent(
messageLength?: number;
}
) {
- posthog.capture("chat_message_sent", {
+ safeCapture("chat_message_sent", {
search_space_id: searchSpaceId,
chat_id: chatId,
has_attachments: options?.hasAttachments ?? false,
@@ -110,14 +113,14 @@ export function trackChatMessageSent(
}
export function trackChatResponseReceived(searchSpaceId: number, chatId: number) {
- posthog.capture("chat_response_received", {
+ safeCapture("chat_response_received", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
}
export function trackChatError(searchSpaceId: number, chatId: number, error?: string) {
- posthog.capture("chat_error", {
+ safeCapture("chat_error", {
search_space_id: searchSpaceId,
chat_id: chatId,
error,
@@ -133,7 +136,7 @@ export function trackDocumentUploadStarted(
fileCount: number,
totalSizeBytes: number
) {
- posthog.capture("document_upload_started", {
+ safeCapture("document_upload_started", {
search_space_id: searchSpaceId,
file_count: fileCount,
total_size_bytes: totalSizeBytes,
@@ -141,35 +144,35 @@ export function trackDocumentUploadStarted(
}
export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) {
- posthog.capture("document_upload_success", {
+ safeCapture("document_upload_success", {
search_space_id: searchSpaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) {
- posthog.capture("document_upload_failure", {
+ safeCapture("document_upload_failure", {
search_space_id: searchSpaceId,
error,
});
}
export function trackDocumentDeleted(searchSpaceId: number, documentId: number) {
- posthog.capture("document_deleted", {
+ safeCapture("document_deleted", {
search_space_id: searchSpaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) {
- posthog.capture("document_bulk_deleted", {
+ safeCapture("document_bulk_deleted", {
search_space_id: searchSpaceId,
count,
});
}
export function trackYouTubeImport(searchSpaceId: number, url: string) {
- posthog.capture("youtube_import_started", {
+ safeCapture("youtube_import_started", {
search_space_id: searchSpaceId,
url,
});
@@ -180,7 +183,7 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) {
// ============================================
export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) {
- posthog.capture("connector_setup_started", {
+ safeCapture("connector_setup_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
});
@@ -191,7 +194,7 @@ export function trackConnectorSetupSuccess(
connectorType: string,
connectorId: number
) {
- posthog.capture("connector_setup_success", {
+ safeCapture("connector_setup_success", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -203,7 +206,7 @@ export function trackConnectorSetupFailure(
connectorType: string,
error?: string
) {
- posthog.capture("connector_setup_failure", {
+ safeCapture("connector_setup_failure", {
search_space_id: searchSpaceId,
connector_type: connectorType,
error,
@@ -215,7 +218,7 @@ export function trackConnectorDeleted(
connectorType: string,
connectorId: number
) {
- posthog.capture("connector_deleted", {
+ safeCapture("connector_deleted", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -227,7 +230,7 @@ export function trackConnectorSynced(
connectorType: string,
connectorId: number
) {
- posthog.capture("connector_synced", {
+ safeCapture("connector_synced", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -239,14 +242,14 @@ export function trackConnectorSynced(
// ============================================
export function trackSettingsViewed(searchSpaceId: number, section: string) {
- posthog.capture("settings_viewed", {
+ safeCapture("settings_viewed", {
search_space_id: searchSpaceId,
section,
});
}
export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) {
- posthog.capture("settings_updated", {
+ safeCapture("settings_updated", {
search_space_id: searchSpaceId,
section,
setting,
@@ -258,14 +261,14 @@ export function trackSettingsUpdated(searchSpaceId: number, section: string, set
// ============================================
export function trackPodcastGenerated(searchSpaceId: number, chatId: number) {
- posthog.capture("podcast_generated", {
+ safeCapture("podcast_generated", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(searchSpaceId: number, tab: string) {
- posthog.capture("sources_tab_viewed", {
+ safeCapture("sources_tab_viewed", {
search_space_id: searchSpaceId,
tab,
});
@@ -283,7 +286,7 @@ export function trackSearchSpaceInviteSent(
hasMaxUses?: boolean;
}
) {
- posthog.capture("search_space_invite_sent", {
+ safeCapture("search_space_invite_sent", {
search_space_id: searchSpaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
@@ -296,7 +299,7 @@ export function trackSearchSpaceInviteAccepted(
searchSpaceName: string,
roleName?: string | null
) {
- posthog.capture("search_space_invite_accepted", {
+ safeCapture("search_space_invite_accepted", {
search_space_id: searchSpaceId,
search_space_name: searchSpaceName,
role_name: roleName,
@@ -304,7 +307,7 @@ export function trackSearchSpaceInviteAccepted(
}
export function trackSearchSpaceInviteDeclined(searchSpaceName?: string) {
- posthog.capture("search_space_invite_declined", {
+ safeCapture("search_space_invite_declined", {
search_space_name: searchSpaceName,
});
}
@@ -314,7 +317,7 @@ export function trackSearchSpaceUserAdded(
searchSpaceName: string,
roleName?: string | null
) {
- posthog.capture("search_space_user_added", {
+ safeCapture("search_space_user_added", {
search_space_id: searchSpaceId,
search_space_name: searchSpaceName,
role_name: roleName,
@@ -326,7 +329,7 @@ export function trackSearchSpaceUsersViewed(
userCount: number,
ownerCount: number
) {
- posthog.capture("search_space_users_viewed", {
+ safeCapture("search_space_users_viewed", {
search_space_id: searchSpaceId,
user_count: userCount,
owner_count: ownerCount,
@@ -342,7 +345,7 @@ export function trackConnectorConnected(
connectorType: string,
connectorId?: number
) {
- posthog.capture("connector_connected", {
+ safeCapture("connector_connected", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -358,7 +361,7 @@ export function trackIndexWithDateRangeOpened(
connectorType: string,
connectorId: number
) {
- posthog.capture("index_with_date_range_opened", {
+ safeCapture("index_with_date_range_opened", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -374,7 +377,7 @@ export function trackIndexWithDateRangeStarted(
hasEndDate?: boolean;
}
) {
- posthog.capture("index_with_date_range_started", {
+ safeCapture("index_with_date_range_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -388,7 +391,7 @@ export function trackQuickIndexClicked(
connectorType: string,
connectorId: number
) {
- posthog.capture("quick_index_clicked", {
+ safeCapture("quick_index_clicked", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -400,7 +403,7 @@ export function trackConfigurePeriodicIndexingOpened(
connectorType: string,
connectorId: number
) {
- posthog.capture("configure_periodic_indexing_opened", {
+ safeCapture("configure_periodic_indexing_opened", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -413,7 +416,7 @@ export function trackPeriodicIndexingStarted(
connectorId: number,
frequencyMinutes: number
) {
- posthog.capture("periodic_indexing_started", {
+ safeCapture("periodic_indexing_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
@@ -426,24 +429,37 @@ export function trackPeriodicIndexingStarted(
// ============================================
export function trackIncentivePageViewed() {
- posthog.capture("incentive_page_viewed");
+ safeCapture("incentive_page_viewed");
}
export function trackIncentiveTaskCompleted(taskType: string, pagesRewarded: number) {
- posthog.capture("incentive_task_completed", {
+ safeCapture("incentive_task_completed", {
task_type: taskType,
pages_rewarded: pagesRewarded,
});
}
export function trackIncentiveTaskClicked(taskType: string) {
- posthog.capture("incentive_task_clicked", {
+ safeCapture("incentive_task_clicked", {
task_type: taskType,
});
}
export function trackIncentiveContactOpened() {
- posthog.capture("incentive_contact_opened");
+ safeCapture("incentive_contact_opened");
+}
+
+// ============================================
+// MARKETING / REFERRAL EVENTS
+// ============================================
+
+export function trackReferralLanding(refCode: string, landingUrl: string) {
+ safeCapture("marketing_referral_landing", {
+ ref_code: refCode,
+ landing_url: landingUrl,
+ $set_once: { first_ref_code: refCode },
+ $set: { latest_ref_code: refCode },
+ });
}
// ============================================
@@ -455,12 +471,20 @@ export function trackIncentiveContactOpened() {
* Call this after successful authentication
*/
export function identifyUser(userId: string, properties?: Record) {
- posthog.identify(userId, properties);
+ try {
+ posthog.identify(userId, properties);
+ } catch {
+ // Silently ignore – ad-blockers may break posthog
+ }
}
/**
* Reset user identity (call on logout)
*/
export function resetUser() {
- posthog.reset();
+ try {
+ posthog.reset();
+ } catch {
+ // Silently ignore – ad-blockers may break posthog
+ }
}