diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index f5bc15e0..70f383a0 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -25,6 +25,7 @@ import Views from './pages/Views/Views'; import Customization from './pages/Customization/Customization'; import Analytics from './pages/Analytics/Analytics'; import OnboardingModal from './components/OnboardingModal'; +import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp'; import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -214,6 +215,30 @@ const ThemedApp: React.FC = () => { const { mode } = useThemeMode(); const muiTheme = useMemo(() => buildMuiTheme(c, mode), [c, mode]); + // Track last action before user leaves and uncaught errors + useEffect(() => { + const handleUnload = () => { + trackEvent('app.last_action', { + last_page: getLastPage(), + last_action: getLastAction(), + time_spent_seconds: getTimeSpent(), + }, true); // useBeacon for reliable delivery during unload + }; + const handleError = (event: ErrorEvent) => { + trackEvent('app.error', { + error_message: event.message, + error_stack: event.error?.stack?.slice(0, 500), + last_page: getLastPage(), + }); + }; + window.addEventListener('beforeunload', handleUnload); + window.addEventListener('error', handleError); + return () => { + window.removeEventListener('beforeunload', handleUnload); + window.removeEventListener('error', handleError); + }; + }, []); + return ( diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index e9b78acd..03fdf81a 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -3,6 +3,7 @@ import { Box, Typography, Modal, Button, CircularProgress, TextField } from '@mu import { useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { API_BASE } from '@/shared/config'; +import { trackEvent } from '@/shared/analytics'; const SUBSCRIPTION_PROVIDERS = [ { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false }, @@ -81,6 +82,7 @@ const OnboardingModal: React.FC = () => { if (nineRouterReady === null) return; // still checking setOpen(true); + trackEvent('onboarding.started', { step: 'profile' }); }, [nineRouterReady]); // Cleanup timers on unmount @@ -109,6 +111,7 @@ const OnboardingModal: React.FC = () => { if (dashboard?.id) { const seedRes = await fetch(`${API_BASE}/dashboards/${dashboard.id}/seed-demo`, { method: 'POST' }); if (seedRes.ok) { + trackEvent('onboarding.completed', { dashboard_id: dashboard.id }); localStorage.setItem('openswarm_walkthrough_pending', 'true'); setOpen(false); // Force full page load to ensure dashboard mounts fresh with walkthrough @@ -142,7 +145,14 @@ const OnboardingModal: React.FC = () => { }), }); } catch {} + trackEvent('onboarding.profile_submitted', { + has_name: !!userName.trim(), + has_email: !!userEmail.trim(), + use_cases: useCases, + use_cases_count: useCases.length, + }); setStep('connect'); + trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady }); }; // Same connect logic as Settings/SubscriptionCards @@ -151,6 +161,7 @@ const OnboardingModal: React.FC = () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } setConnecting(providerId); + trackEvent('onboarding.provider_selected', { provider: providerId }); // Delay before calling connect — avoids Claude OAuth rate limit on retries await new Promise(r => setTimeout(r, 1000)); @@ -186,6 +197,7 @@ const OnboardingModal: React.FC = () => { if (pd.success) { clearInterval(timer); pollTimerRef.current = null; + trackEvent('onboarding.provider_connected', { provider: providerId }); dismiss(); } } catch {} @@ -209,6 +221,7 @@ const OnboardingModal: React.FC = () => { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } + trackEvent('onboarding.provider_connected', { provider: providerId }); dismiss(); } } catch {} @@ -237,6 +250,7 @@ const OnboardingModal: React.FC = () => { }), }); } catch {} + trackEvent('onboarding.provider_connected', { provider: providerId }); dismiss(); } }; @@ -257,8 +271,8 @@ const OnboardingModal: React.FC = () => { } }; - const handleApiKey = () => dismiss(); - const handleSkip = () => dismiss(); + const handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); }; + const handleSkip = () => { trackEvent(step === 'profile' ? 'onboarding.profile_skipped' : 'onboarding.connect_skipped'); dismiss(); }; if (!open) return null; diff --git a/frontend/src/app/components/OnboardingWalkthrough.tsx b/frontend/src/app/components/OnboardingWalkthrough.tsx index be4d8fa6..407839bf 100644 --- a/frontend/src/app/components/OnboardingWalkthrough.tsx +++ b/frontend/src/app/components/OnboardingWalkthrough.tsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { trackEvent } from '@/shared/analytics'; export interface WalkthroughStep { target: string; // data-onboarding="" selector @@ -104,6 +105,18 @@ const OnboardingWalkthrough: React.FC = ({ onComplete }) => { const totalSteps = STEPS.length; const isLastStep = currentStep === totalSteps - 1; + // Track walkthrough start on mount + useEffect(() => { + trackEvent('walkthrough.started'); + }, []); + + // Track each step viewed + useEffect(() => { + if (step) { + trackEvent('walkthrough.step_viewed', { step: currentStep, step_name: step.target || 'done' }); + } + }, [currentStep, step]); + // Find target element and compute spotlight + tooltip position const updatePosition = useCallback(() => { if (!step) return; @@ -198,15 +211,17 @@ const OnboardingWalkthrough: React.FC = ({ onComplete }) => { const handleNext = useCallback(() => { if (isLastStep) { + trackEvent('walkthrough.completed', { steps_viewed: currentStep + 1 }); onComplete(); } else { setCurrentStep((s) => s + 1); } - }, [isLastStep, onComplete]); + }, [isLastStep, onComplete, currentStep]); const handleSkip = useCallback(() => { + trackEvent('walkthrough.skipped', { step: currentStep, step_name: step?.target || 'done' }); onComplete(); - }, [onComplete]); + }, [onComplete, currentStep, step]); // Allow clicking the spotlight target to advance for action steps useEffect(() => { @@ -219,6 +234,7 @@ const OnboardingWalkthrough: React.FC = ({ onComplete }) => { if (!el) return; const handler = () => { + trackEvent('walkthrough.step_action', { step: currentStep, step_name: step.target }); setTimeout(() => handleNext(), 300); }; el.addEventListener('click', handler, { once: true }); diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 30dc0846..cd94b1f2 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import DashboardHeader from './DashboardHeader'; +import { trackEvent } from '@/shared/analytics'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { store } from '@/shared/state/store'; import { @@ -283,6 +284,7 @@ const DashboardInner: React.FC = () => { }, [tickEdgePan]); const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => { + if (didDrag) trackEvent('dashboard.card_dragged'); stopEdgePan(); if (isMultiDragRef.current && didDrag) { const items = selection.selectedArray() @@ -320,6 +322,7 @@ const DashboardInner: React.FC = () => { const clickTimerRef = useRef | null>(null); const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => { + trackEvent('dashboard.card_clicked', { card_type: type, shift: shiftKey }); if (shiftKey) { selection.selectCard(id, type, true); return; @@ -401,11 +404,13 @@ const DashboardInner: React.FC = () => { const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; + trackEvent('dashboard.canvas_double_clicked'); canvas.actions.fitToView(); }, [canvas.actions]); // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click) const handleCardDoubleClick = useCallback((id: string, type: CardType) => { + trackEvent('dashboard.card_double_clicked', { card_type: type }); if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); clickTimerRef.current = null; @@ -422,6 +427,19 @@ const DashboardInner: React.FC = () => { }, 100); }, [getCardRect, canvas.actions, dispatch]); + // Track dashboard engagement time + useEffect(() => { + if (!dashboardId) return; + const startTime = Date.now(); + trackEvent('dashboard.opened', { dashboard_id: dashboardId }); + return () => { + trackEvent('dashboard.closed', { + dashboard_id: dashboardId, + time_spent_seconds: Math.round((Date.now() - startTime) / 1000), + }); + }; + }, [dashboardId]); + useEffect(() => { if (!dashboardId) return; hasFittedRef.current = false; @@ -743,6 +761,7 @@ const DashboardInner: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; e.preventDefault(); setSearchPaletteOpen(true); + trackEvent('dashboard.search_opened'); }; window.addEventListener('keydown', handleSearch); return () => window.removeEventListener('keydown', handleSearch); @@ -954,6 +973,7 @@ const DashboardInner: React.FC = () => { } // Collapse current, expand + navigate to target + bring to front + trackEvent('dashboard.arrow_navigated', { direction, from_card: currentFocused, to_card: target.id }); dispatch(collapseSession(currentFocused)); if (target.type === 'agent') { dispatch(expandSession(target.id)); @@ -1033,6 +1053,7 @@ const DashboardInner: React.FC = () => { selectedBrowserIds?: string[], ) => { setToolbarOpen(false); + trackEvent('dashboard.agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); const draftId = `draft-${Date.now().toString(36)}`; @@ -1138,6 +1159,7 @@ const DashboardInner: React.FC = () => { }, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]); const handleAddBrowser = useCallback(() => { + trackEvent('dashboard.browser_added'); const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards)); dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds })); setTimeout(() => { @@ -1169,6 +1191,7 @@ const DashboardInner: React.FC = () => { // Context-aware fit: if a card is selected, zoom to it; otherwise fit all const handleFitToView = useCallback(() => { + trackEvent('dashboard.fit_to_view', { has_selection: selection.selectedIds.size > 0 }); if (selection.selectedIds.size === 1) { const [[id, type]] = selection.selectedIds; const rect = getCardRect(id, type); @@ -1181,6 +1204,7 @@ const DashboardInner: React.FC = () => { }, [selection.selectedIds, getCardRect, canvas.actions]); const handleTidy = useCallback(() => { + trackEvent('dashboard.tidy_layout'); const currentExpanded = store.getState().agents.expandedSessionIds; dispatch(tidyLayout({ expandedSessionIds: currentExpanded })); diff --git a/frontend/src/shared/analytics.ts b/frontend/src/shared/analytics.ts new file mode 100644 index 00000000..69a01fb3 --- /dev/null +++ b/frontend/src/shared/analytics.ts @@ -0,0 +1,26 @@ +import { API_BASE } from './config'; + +let _lastAction = ''; +let _lastPage = ''; +let _appStartTime = Date.now(); + +export function trackEvent(eventType: string, properties?: Record, useBeacon = false) { + _lastAction = eventType; + _lastPage = window.location.hash || window.location.pathname; + + const body = JSON.stringify({ event_type: eventType, properties }); + if (useBeacon && navigator.sendBeacon) { + // sendBeacon is guaranteed to complete even during page unload + navigator.sendBeacon(`${API_BASE}/analytics/event`, new Blob([body], { type: 'application/json' })); + } else { + fetch(`${API_BASE}/analytics/event`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }).catch(() => {}); + } +} + +export function getLastAction() { return _lastAction; } +export function getLastPage() { return _lastPage; } +export function getTimeSpent() { return Math.round((Date.now() - _appStartTime) / 1000); }