feat(backend): integrate PostHog analytics for enhanced observability

- Added PostHog configuration options to .env.example files for both Docker and Surfsense backend.
- Introduced PostHog dependency in pyproject.toml.
- Implemented analytics middleware to capture various events across the application, including user authentication, automation runs, and API requests.
- Enhanced existing routes and services to emit analytics events, providing insights into user interactions and system performance.
- Ensured graceful shutdown of analytics clients in worker processes and application lifecycles.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-22 22:16:28 -07:00
parent ca4f231577
commit dbedf0cfa5
47 changed files with 1618 additions and 513 deletions

View file

@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() {
const t = useTranslations("auth");
@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password",
});
// Track successful login
trackLoginSuccess("local");
// auth_login_success is now emitted server-side
// (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message
setTimeout(() => {

View file

@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
import {
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false,
});
// Track successful registration
trackRegistrationSuccess();
// auth_registration_success is now emitted server-side
// (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast
toast.success(t("register_success"), {

View file

@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date";
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
const canPrev = pageIndex > 0;
const canNext = displayEnd < totalItems;
useEffect(() => {
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
// workspace_users_viewed removed — redundant with $pageview.
if (accessLoading || membersLoading) {
return (
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
Invite members
</Button>
) : (
<CreateInviteDialog
roles={roles}
onCreateInvite={handleCreateInvite}
workspaceId={workspaceId}
/>
<CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
)}
{invitesLoading ? (
<Button
@ -617,11 +607,9 @@ function MemberRow({
function CreateInviteDialog({
roles,
onCreateInvite,
workspaceId,
}: {
roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
workspaceId: number;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
@ -653,12 +641,7 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data);
setCreatedInvite(invite);
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
trackWorkspaceInviteSent(workspaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
});
// workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
} catch (error) {
console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again.");

View file

@ -16,7 +16,7 @@ import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { use, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button";
@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils";
import {
trackWorkspaceInviteAccepted,
trackWorkspaceInviteDeclined,
trackWorkspaceUserAdded,
} from "@/lib/posthog/events";
import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() {
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
setAccepted(true);
setAcceptedData(result);
// Track invite accepted and user added events
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
// workspace_invite_accepted + workspace_user_added are now emitted
// server-side (rbac_routes.accept_invite) — the server redirect is
// the authoritative join point.
}
} catch (err: any) {
setError(err.message || "Failed to accept invite");

View file

@ -8,18 +8,11 @@ import type {
} from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import {
trackAutomationCreated,
trackAutomationCreateFailed,
trackAutomationDeleted,
trackAutomationDeleteFailed,
trackAutomationStatusChanged,
trackAutomationTriggerAdded,
trackAutomationTriggerAddFailed,
trackAutomationTriggerRemoved,
trackAutomationTriggerRemoveFailed,
trackAutomationTriggerUpdated,
trackAutomationTriggerUpdateFailed,
trackAutomationUpdated,
trackAutomationUpdateFailed,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request);
},
onSuccess: (automation, variables) => {
onSuccess: (_automation, variables) => {
invalidateList(variables.workspace_id);
toast.success("Automation created");
trackAutomationCreated({
workspace_id: variables.workspace_id,
automation_id: automation.id,
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
chat_model_id: variables.definition.models?.chat_model_id,
image_gen_model_id: variables.definition.models?.image_gen_model_id,
vision_model_id: variables.definition.models?.vision_model_id,
tags_count: variables.definition.metadata?.tags?.length,
});
// automation_created is now emitted server-side (AutomationService.create).
},
onError: (error: Error, variables) => {
console.error("Error creating automation:", error);
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
invalidateDetail(vars.automationId);
invalidateList(automation.workspace_id);
toast.success("Automation updated");
// A status-only patch (pause/resume/archive) is a distinct action from a
// definition/name edit, so split it into its own event.
if (vars.patch.status && !vars.patch.definition) {
trackAutomationStatusChanged({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
next_status: vars.patch.status,
});
} else {
trackAutomationUpdated({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
has_definition_change: !!vars.patch.definition,
has_name_change: vars.patch.name != null,
has_description_change: vars.patch.description !== undefined,
task_count: vars.patch.definition?.plan?.length,
});
}
// automation_updated / automation_status_changed are now emitted
// server-side (AutomationService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating automation:", error);
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
trackAutomationDeleted({
automation_id: vars.automationId,
workspace_id: vars.workspaceId,
});
// automation_deleted is now emitted server-side (AutomationService.delete).
},
onError: (error: Error, vars) => {
console.error("Error deleting automation:", error);
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload);
},
onSuccess: (trigger, vars) => {
onSuccess: (_trigger, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger added");
trackAutomationTriggerAdded({
automation_id: vars.automationId,
trigger_id: trigger.id,
trigger_type: trigger.type,
enabled: trigger.enabled,
has_cron: !!trigger.params?.cron,
});
// automation_trigger_added is now emitted server-side (TriggerService.add).
},
onError: (error: Error, vars) => {
console.error("Error adding trigger:", error);
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger updated");
const change: "enabled" | "params" | "other" = vars.patch.params
? "params"
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
? "enabled"
: "other";
trackAutomationTriggerUpdated({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
change,
enabled: vars.patch.enabled ?? undefined,
});
// automation_trigger_updated is now emitted server-side (TriggerService.update).
},
onError: (error: Error, vars) => {
console.error("Error updating trigger:", error);
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger removed");
trackAutomationTriggerRemoved({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
});
// automation_trigger_removed is now emitted server-side (TriggerService.remove).
},
onError: (error: Error, vars) => {
console.error("Error removing trigger:", error);

View file

@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
trackConnectorConnected(
Number(workspaceId),
oauthConnector.connectorType,
newConnector.id
);
// connector_connected is now emitted server-side (after_insert
// listener in search_source_connectors_routes.py) — covers the
// OAuth callback redirect the frontend often never observes.
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
// Track webcrawler connector connected
trackConnectorConnected(
Number(workspaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
// connector_connected is now emitted server-side (after_insert).
const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
// connector_connected is now emitted server-side (after_insert).
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id,
});
// Track connector deleted event
trackConnectorDeleted(
Number(workspaceId),
editingConnector.connector_type,
editingConnector.id
);
// connector_deleted is now emitted server-side
// (search_source_connectors_routes.delete_search_source_connector).
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"

View file

@ -28,7 +28,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
description: values.description || "",
});
trackWorkspaceCreated(result.id, values.name);
// workspace_created is now emitted server-side (workspaces_routes.py)
// so PAT/MCP-created workspaces are also counted.
// Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop.

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import {
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils";
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
const queryClient = useQueryClient();
const workspaceId = getWorkspaceIdParam(params) ?? "";
useEffect(() => {
trackIncentivePageViewed();
}, []);
// incentive_page_viewed removed — redundant with $pageview.
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
onSuccess: (response) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
}
// incentive_task_completed is now emitted server-side
// (incentive_tasks_routes.complete_task) where credit is granted.
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
}

View file

@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
import {
getAcceptedFileTypes,
getSupportedExtensions,
@ -380,13 +376,14 @@ export function DocumentUploadTab({
setUploadProgress(Math.round((uploaded / total) * 100));
}
trackDocumentUploadSuccess(Number(workspaceId), total);
// Ingestion outcome is now emitted server-side
// (document_processing_completed/_failed in document_tasks.py); the
// upload POST succeeding only means the file was accepted, not processed.
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
setFolderUpload(null);
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});
@ -421,7 +418,7 @@ export function DocumentUploadTab({
onSuccess: () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100);
trackDocumentUploadSuccess(Number(workspaceId), files.length);
// Ingestion outcome now server-side (document_processing_*).
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.();
},
@ -429,7 +426,6 @@ export function DocumentUploadTab({
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`,
});

View file

@ -156,6 +156,40 @@ transport.
Celery runtime, and runtime gauges appear within one export interval.
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
## Product Analytics (PostHog)
Separate from OpenTelemetry, the backend can emit server-side product events to
PostHog. This is the authoritative source for outcome events (chats, document
ingestion, connector indexing, billing, automations) because it captures traffic
the browser never sees — MCP clients, personal-access-token scripts, and Celery
background jobs. It is fully opt-in and mirrors the OTel contract: with
`POSTHOG_API_KEY` unset, every capture is a silent no-op.
Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
server events merge onto the same PostHog persons the web app identifies by user
id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
they reach the API, Celery worker, and beat services via `env_file`, so no
compose changes are needed:
```dotenv
POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_AI_PRIVACY_MODE=true
```
`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
LLM prompt and completion bodies shipped to PostHog's AI observability views.
Every backend event is stamped `source=backend` (so it is distinguishable from
frontend captures), carries `auth_method` / `client` for surface attribution, and
sends `disable_geoip=true` so the server IP never overwrites a person's real
location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
traces via the PostHog LangChain callback handler, keyed by turn and chat id.
Keep event properties low-cardinality. Never attach user content — workspace
names, connector titles, document titles, prompts, or raw queries — as event
properties; they carry no aggregation value and are a privacy risk.
## Out Of Scope
- Frontend/browser OpenTelemetry.

View file

@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
try {
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
runIdRef.current = started.run_id;
trackWeeklyUser("api_run", workspaceId);
// weekly_users removed — WAU is derived server-side from
// scraper_run_completed / chat_turn_completed.
setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal);
} catch (e) {

View file

@ -39,6 +39,30 @@ function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
}
/**
* Send an API failure to PostHog error tracking. Scoped by the caller to only
* 5xx server faults + network outages 4xx responses are expected behavior.
* Lazy-imports posthog-js so an ad-blocker can never break the request path.
*/
function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
}
export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
@ -281,29 +305,20 @@ class BaseApiService {
throw new AbortedError();
}
if (error instanceof TypeError && !(error instanceof AppError)) {
throw new NetworkError(
const networkError = new NetworkError(
"Unable to connect to the server. Check your internet connection and try again."
);
// Network failures are genuine outages worth tracking.
captureApiException(networkError, url, options?.method);
throw networkError;
}
console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
// Only 5xx server faults are unexpected. 4xx (validation, authz, 404)
// are expected behavior — capturing them was billable error-tracking
// noise. AuthenticationError (401) is a 4xx and stays excluded.
if (error instanceof AppError && error.status >= 500) {
captureApiException(error, url, options?.method);
}
throw error;
}

View file

@ -58,7 +58,6 @@ import {
import { buildBackendUrl } from "@/lib/env-config";
import {
trackChatBlocked,
trackChatCreated,
trackChatErrorDetailed,
trackChatMessageSent,
trackChatResponseReceived,
@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
trackChatCreated(workspaceId, currentThreadId);
// chat_created is now emitted server-side (new_chat_routes.create_thread)
// so PAT/MCP-created threads are also counted.
isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid

View file

@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/**
* PostHog Analytics Event Definitions
* PostHog Analytics Event Definitions (frontend)
*
* 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
* SCOPE: this file now holds only *intent* and client-perceived *UX* events.
* Authoritative *outcome* events (resource creation, task/ingestion/indexing
* completion, auth success, billing) are emitted server-side in
* surfsense_backend/app/observability/analytics.py they are reliable
* regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
* clients. Do NOT re-add optimistic outcome captures here; they double-count.
*
* Categories:
* - auth: Authentication events
* - workspace: Search space management
* - document: Document management
* - chat: Chat and messaging (authenticated + anonymous)
* - connector: External connector events (all lifecycle stages)
* - contact: Contact form events
* - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking
* Events follow a consistent naming convention: category_action
*/
function safeCapture(event: string, properties?: Record<string, unknown>) {
@ -43,17 +39,13 @@ function compact<T extends object>(obj: T): Record<string, unknown> {
}
// ============================================
// AUTH EVENTS
// AUTH EVENTS (attempts + failures only; successes are server-side)
// ============================================
export function trackLoginAttempt(method: "local" | "google") {
safeCapture("auth_login_attempt", { method });
}
export function trackLoginSuccess(method: "local" | "google") {
safeCapture("auth_login_success", { method });
}
export function trackLoginFailure(method: "local" | "google", error?: string) {
safeCapture("auth_login_failure", { method, error });
}
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
safeCapture("auth_registration_attempt");
}
export function trackRegistrationSuccess() {
safeCapture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) {
safeCapture("auth_registration_failure", { error });
}
@ -75,56 +63,9 @@ export function trackLogout() {
}
// ============================================
// SEARCH SPACE EVENTS
// CHAT EVENTS (client-perceived UX)
// ============================================
export function trackWorkspaceCreated(workspaceId: number, name: string) {
safeCapture("workspace_created", {
workspace_id: workspaceId,
name,
});
}
export function trackWorkspaceDeleted(workspaceId: number) {
safeCapture("workspace_deleted", {
workspace_id: workspaceId,
});
}
export function trackWorkspaceViewed(workspaceId: number) {
safeCapture("workspace_viewed", {
workspace_id: workspaceId,
});
}
// ============================================
// ACTIVE-USER (WAU) EVENT
// ============================================
/**
* Single signal for active-user counting. Fired whenever a user sends a
* chat message or starts an API run, so a "weekly unique users on
* weekly_users" insight in PostHog is our WAU number.
*
* ponytail: frontend-only capture API runs made directly against the
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
* server-side capture in the backend if that ever matters.
*/
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
}
// ============================================
// CHAT EVENTS
// ============================================
export function trackChatCreated(workspaceId: number, chatId: number) {
safeCapture("chat_created", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackChatMessageSent(
workspaceId: number,
chatId: number,
@ -141,7 +82,6 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength,
});
trackWeeklyUser("chat_message", workspaceId);
}
export function trackChatResponseReceived(workspaceId: number, chatId: number) {
@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
* flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*.
*
* Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
* frontend anon distinct id is what merges into the person at signup,
* powering the anonymous-to-registered conversion funnel.
*/
export function trackAnonymousChatMessageSent(options: {
modelSlug: string;
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
}
// ============================================
// DOCUMENT EVENTS
// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
// ============================================
export function trackDocumentUploadStarted(
@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
});
}
export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
safeCapture("document_upload_success", {
workspace_id: workspaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
safeCapture("document_upload_failure", {
workspace_id: workspaceId,
error,
});
}
export function trackDocumentDeleted(workspaceId: number, documentId: number) {
safeCapture("document_deleted", {
workspace_id: workspaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
safeCapture("document_bulk_deleted", {
workspace_id: workspaceId,
count,
});
}
export function trackYouTubeImport(workspaceId: number, url: string) {
safeCapture("youtube_import_started", {
workspace_id: workspaceId,
url,
});
}
// ============================================
// CONNECTOR EVENTS (generic lifecycle dispatcher)
// CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
// ============================================
//
// All connector events go through `trackConnectorEvent`. The connector's
// human-readable title and its group (oauth/composio/crawler/other) are
// auto-attached from the shared registry in `connector-constants.ts`, so
// adding a new connector to that list is the only change required for it
// to show up correctly in PostHog dashboards.
// group (oauth/composio/crawler/other) is auto-attached from the shared
// registry, so adding a new connector to that list is the only change
// required for it to show up correctly in PostHog dashboards.
export type ConnectorEventStage =
| "setup_started"
| "setup_success"
| "setup_failure"
| "oauth_initiated"
| "connected"
| "deleted"
| "synced";
| "oauth_initiated";
export interface ConnectorEventOptions {
workspaceId?: number | null;
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
/**
* Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent.
*
* ``connector_title`` is intentionally NOT sent it's a display label with
* no aggregation value; segment on ``connector_type`` / ``connector_group``.
*/
export function trackConnectorEvent(
stage: ConnectorEventStage,
@ -327,7 +235,6 @@ export function trackConnectorEvent(
error: options.error,
}),
connector_type: meta.connector_type,
connector_title: meta.connector_title,
connector_group: meta.connector_group,
is_oauth: meta.is_oauth,
...(options.extra ?? {}),
@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
});
}
export function trackConnectorDeleted(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
}
export function trackConnectorSynced(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
}
// ============================================
// SETTINGS EVENTS
// ============================================
export function trackSettingsViewed(workspaceId: number, section: string) {
safeCapture("settings_viewed", {
workspace_id: workspaceId,
section,
});
}
export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
safeCapture("settings_updated", {
workspace_id: workspaceId,
section,
setting,
});
}
// ============================================
// FEATURE USAGE EVENTS
// ============================================
export function trackPodcastGenerated(workspaceId: number, chatId: number) {
safeCapture("podcast_generated", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(workspaceId: number, tab: string) {
safeCapture("sources_tab_viewed", {
workspace_id: workspaceId,
tab,
});
}
export function trackDesktopDownloadClicked(options: {
os: string;
placement: "sidebar_collapsed" | "sidebar_expanded";
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
}
// ============================================
// SEARCH SPACE INVITE EVENTS
// ============================================
export function trackWorkspaceInviteSent(
workspaceId: number,
options?: {
roleName?: string;
hasExpiry?: boolean;
hasMaxUses?: boolean;
}
) {
safeCapture("workspace_invite_sent", {
workspace_id: workspaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
has_max_uses: options?.hasMaxUses ?? false,
});
}
export function trackWorkspaceInviteAccepted(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_invite_accepted", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
export function trackWorkspaceUserAdded(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_user_added", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceUsersViewed(
workspaceId: number,
userCount: number,
ownerCount: number
) {
safeCapture("workspace_users_viewed", {
workspace_id: workspaceId,
user_count: userCount,
owner_count: ownerCount,
});
}
// ============================================
// CONNECTOR CONNECTION EVENTS
// ============================================
export function trackConnectorConnected(
workspaceId: number,
connectorType: string,
connectorId?: number
) {
trackConnectorEvent("connected", connectorType, {
workspaceId,
connectorId: connectorId ?? undefined,
});
}
// ============================================
// INDEXING EVENTS
// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
// ============================================
export function trackIndexWithDateRangeOpened(
@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
}
// ============================================
// INCENTIVE TASKS EVENTS
// SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
// ============================================
export function trackIncentivePageViewed() {
safeCapture("incentive_page_viewed");
}
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
safeCapture("incentive_task_completed", {
task_type: taskType,
credit_micros_rewarded: creditMicrosRewarded,
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
// ============================================
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
// ============================================
export function trackIncentiveTaskClicked(taskType: string) {
safeCapture("incentive_task_clicked", {
task_type: taskType,
@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
}
// ============================================
// AUTOMATION EVENTS
// AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
// ============================================
interface AutomationCreatedProps {
workspace_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
chat_model_id?: number;
image_gen_model_id?: number;
vision_model_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props));
}
export function trackAutomationUpdated(props: {
automation_id: number;
workspace_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
workspace_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props));
}
export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props));
}
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props));
}
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number;
trigger_id: number;
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
safeCapture("automation_trigger_update_failed", compact(props));
}
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number;
trigger_id: number;