feat: add PostHog product analytics for key user actions (#110)
Tracks 9 key events: account_created, login_success, flow_viewed, session_started, session_completed, export_generated, ai_feature_used, psa_connected, session_shared. Identifies users on login, resets on logout. Autocapture disabled — only explicit discrete events. Set VITE_POSTHOG_KEY in environment to enable. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #110.
This commit is contained in:
102
frontend/src/lib/analytics.ts
Normal file
102
frontend/src/lib/analytics.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* PostHog product analytics wrapper.
|
||||
*
|
||||
* Tracks key user actions to understand product usage, activation,
|
||||
* and engagement. All events are lightweight discrete actions — no
|
||||
* pageviews or mouse tracking.
|
||||
*
|
||||
* Free tier: 1M events/month (more than enough for current scale).
|
||||
*/
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY as string | undefined
|
||||
const POSTHOG_HOST = (import.meta.env.VITE_POSTHOG_HOST as string) || 'https://us.i.posthog.com'
|
||||
|
||||
let initialized = false
|
||||
|
||||
/** Initialize PostHog. Call once on app startup. */
|
||||
export function initAnalytics() {
|
||||
if (initialized || !POSTHOG_KEY) return
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
autocapture: false, // We track events explicitly — no auto-capture
|
||||
capture_pageview: false, // SPA — we'll track meaningful navigations, not every route
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage',
|
||||
})
|
||||
initialized = true
|
||||
}
|
||||
|
||||
/** Identify a logged-in user. Call after login/fetchUser. */
|
||||
export function identifyUser(user: {
|
||||
id: string
|
||||
email: string
|
||||
role?: string
|
||||
is_super_admin?: boolean
|
||||
account_id?: string
|
||||
}) {
|
||||
if (!initialized) return
|
||||
posthog.identify(user.id, {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
is_super_admin: user.is_super_admin,
|
||||
})
|
||||
if (user.account_id) {
|
||||
posthog.group('account', user.account_id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset identity on logout. */
|
||||
export function resetAnalytics() {
|
||||
if (!initialized) return
|
||||
posthog.reset()
|
||||
}
|
||||
|
||||
// ─── Event Tracking ─────────────────────────────────────────────
|
||||
|
||||
/** Track a named event with optional properties. */
|
||||
function track(event: string, properties?: Record<string, unknown>) {
|
||||
if (!initialized) return
|
||||
posthog.capture(event, properties)
|
||||
}
|
||||
|
||||
// Activation events
|
||||
export const analytics = {
|
||||
/** User created a new account */
|
||||
accountCreated: () => track('account_created'),
|
||||
|
||||
/** User logged in successfully */
|
||||
loginSuccess: () => track('login_success'),
|
||||
|
||||
/** User viewed a flow (opened the navigate page) */
|
||||
flowViewed: (props: { flow_id: string; flow_type: string; flow_name: string }) =>
|
||||
track('flow_viewed', props),
|
||||
|
||||
/** User started a session */
|
||||
sessionStarted: (props: { session_id: string; flow_id: string; flow_type: string }) =>
|
||||
track('session_started', props),
|
||||
|
||||
/** User completed a session */
|
||||
sessionCompleted: (props: { session_id: string; flow_type: string; outcome: string }) =>
|
||||
track('session_completed', props),
|
||||
|
||||
/** User generated an export (markdown, html, PDF, PSA) */
|
||||
exportGenerated: (props: { session_id: string; format: string }) =>
|
||||
track('export_generated', props),
|
||||
|
||||
/** User used an AI feature */
|
||||
aiFeatureUsed: (props: { feature: 'copilot' | 'flow_assist' | 'session_to_flow' | 'kb_accelerator' | 'assistant_chat' }) =>
|
||||
track('ai_feature_used', props),
|
||||
|
||||
/** User connected a PSA integration */
|
||||
psaConnected: (props: { provider: string }) =>
|
||||
track('psa_connected', props),
|
||||
|
||||
/** User created a shared session link */
|
||||
sessionShared: (props: { session_id: string; visibility: string }) =>
|
||||
track('session_shared', props),
|
||||
|
||||
/** User created a new flow */
|
||||
flowCreated: (props: { flow_type: string; method: 'manual' | 'ai' | 'import' | 'session_to_flow' }) =>
|
||||
track('flow_created', props),
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import "./instrument"; // Sentry must init before any other imports
|
||||
import { initAnalytics } from './lib/analytics'
|
||||
|
||||
initAnalytics() // PostHog product analytics
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
|
||||
import { UpdateTicketModal } from '@/components/session/UpdateTicketModal'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import { addRecentFlow } from '@/lib/recentFlows'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { useTicketContext } from '@/hooks/useTicketContext'
|
||||
import { TicketContextPanel } from '@/components/session/TicketContextPanel'
|
||||
|
||||
@@ -227,6 +228,7 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
setTree(treeData)
|
||||
addRecentFlow({ id: treeData.id, name: treeData.name, tree_type: treeData.tree_type })
|
||||
analytics.flowViewed({ flow_id: treeData.id, flow_type: treeData.tree_type, flow_name: treeData.name })
|
||||
|
||||
// If resuming an existing session
|
||||
if (locationState?.sessionId) {
|
||||
@@ -252,6 +254,7 @@ export function ProceduralNavigationPage() {
|
||||
session_variables: Object.keys(variables).length > 0 ? variables : undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
analytics.sessionStarted({ session_id: newSession.id, flow_id: id, flow_type: treeData?.tree_type || 'procedural' })
|
||||
setSessionVariables(variables)
|
||||
|
||||
// Initialize step states — use passed treeData since `tree` state may not have committed yet
|
||||
@@ -401,6 +404,7 @@ export function ProceduralNavigationPage() {
|
||||
})
|
||||
setCompletedAt(completedTime)
|
||||
setIsComplete(true)
|
||||
analytics.sessionCompleted({ session_id: session.id, flow_type: tree?.tree_type || 'procedural', outcome: 'resolved' })
|
||||
if (!hasBeenRated(session.id)) {
|
||||
setShowCsatModal(true)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
|
||||
import { UpdateTicketModal } from '@/components/session/UpdateTicketModal'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import { addRecentFlow } from '@/lib/recentFlows'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { useTicketContext } from '@/hooks/useTicketContext'
|
||||
import { TicketContextPanel } from '@/components/session/TicketContextPanel'
|
||||
|
||||
@@ -335,6 +336,7 @@ export function TreeNavigationPage() {
|
||||
|
||||
setTree(treeData)
|
||||
addRecentFlow({ id: treeData.id, name: treeData.name, tree_type: treeData.tree_type })
|
||||
analytics.flowViewed({ flow_id: treeData.id, flow_type: treeData.tree_type, flow_name: treeData.name })
|
||||
|
||||
// If resuming a session
|
||||
if (locationState?.sessionId) {
|
||||
@@ -367,6 +369,7 @@ export function TreeNavigationPage() {
|
||||
client_name: clientName || undefined,
|
||||
})
|
||||
setSession(newSession)
|
||||
analytics.sessionStarted({ session_id: newSession.id, flow_id: tree.id, flow_type: tree.tree_type })
|
||||
// Initialize currentNodeId to the tree's actual root (may not be 'root')
|
||||
const rootId = tree.tree_structure?.id || 'root'
|
||||
setCurrentNodeId(rootId)
|
||||
@@ -512,6 +515,7 @@ export function TreeNavigationPage() {
|
||||
}
|
||||
|
||||
await sessionsApi.complete(session.id, data)
|
||||
analytics.sessionCompleted({ session_id: session.id, flow_type: tree?.tree_type || 'troubleshooting', outcome: data.outcome })
|
||||
|
||||
setShowOutcomeModal(false)
|
||||
setPendingCompletionDecision(null)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { clearCachedQuota } from '@/hooks/useCachedQuota'
|
||||
|
||||
@@ -49,6 +50,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Fetch user info
|
||||
await get().fetchUser()
|
||||
analytics.loginSuccess()
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { detail?: unknown } } }
|
||||
const rawDetail = axiosErr.response?.data?.detail
|
||||
@@ -62,6 +64,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await authApi.register(data)
|
||||
analytics.accountCreated()
|
||||
// After registration, log the user in
|
||||
await get().login({ email: data.email, password: data.password })
|
||||
} catch (error: unknown) {
|
||||
@@ -83,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
localStorage.removeItem('refresh_token')
|
||||
clearCachedQuota()
|
||||
Sentry.setUser(null)
|
||||
resetAnalytics()
|
||||
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
||||
}
|
||||
},
|
||||
@@ -109,6 +113,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Set Sentry user context for error attribution
|
||||
Sentry.setUser({ id: user.id, email: user.email })
|
||||
|
||||
// Identify user in PostHog for product analytics
|
||||
identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id })
|
||||
|
||||
set({ user, account, subscription, isLoading: false })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
||||
|
||||
Reference in New Issue
Block a user