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

@ -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;