diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 6e9b5c22..9ea995ac 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -143,6 +143,8 @@ jobs: run: cd frontend && npm run build env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + VITE_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + VITE_POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} - name: Create Sentry release uses: getsentry/action-release@v3 diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index 5683e940..9c1c9048 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -304,7 +304,7 @@ pub trait Deployment: Clone + Send + Sync + 'static { "use_existing_repo": create_data.use_existing_repo, "has_setup_script": create_data.setup_script.is_some(), "has_dev_script": create_data.dev_script.is_some(), - "source": "auto_setup", + "trigger": "auto_setup", }), ) .await; diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index 2dc82848..8f3a21ec 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -61,6 +61,7 @@ impl Environment { #[derive(Debug, Serialize, Deserialize, TS)] pub struct UserSystemInfo { pub config: Config, + pub analytics_user_id: String, #[serde(flatten)] pub profiles: ExecutorConfigs, pub environment: Environment, @@ -77,6 +78,7 @@ async fn get_user_system_info( let user_system_info = UserSystemInfo { config: config.clone(), + analytics_user_id: deployment.user_id().to_string(), profiles: ExecutorConfigs::get_cached(), environment: Environment::new(), capabilities: { diff --git a/crates/server/src/routes/github.rs b/crates/server/src/routes/github.rs index 2dd03f33..ee6233aa 100644 --- a/crates/server/src/routes/github.rs +++ b/crates/server/src/routes/github.rs @@ -182,7 +182,7 @@ pub async fn create_project_from_github( "clone_url": payload.clone_url, "has_setup_script": has_setup_script, "has_dev_script": has_dev_script, - "source": "github", + "trigger": "github", })), ) .await; diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index ba0fa362..392d2c08 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -158,7 +158,7 @@ pub async fn create_project( "use_existing_repo": use_existing_repo, "has_setup_script": project.setup_script.is_some(), "has_dev_script": project.dev_script.is_some(), - "source": "manual", + "trigger": "manual", }), ) .await; diff --git a/crates/services/src/services/analytics.rs b/crates/services/src/services/analytics.rs index 03ec8604..2c091f0b 100644 --- a/crates/services/src/services/analytics.rs +++ b/crates/services/src/services/analytics.rs @@ -77,6 +77,7 @@ impl AnalyticsService { ); props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); props.insert("device".to_string(), get_device_info()); + props.insert("source".to_string(), json!("backend")); } payload["properties"] = event_properties; } diff --git a/frontend/package.json b/frontend/package.json index 213c97c4..b08eda40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "lexical": "^0.36.2", "lucide-react": "^0.539.0", "markdown-to-jsx": "^7.7.13", + "posthog-js": "^1.276.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "^5.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c932b66..17fb0370 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import i18n from '@/i18n'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; import { NormalLayout } from '@/components/layout/NormalLayout'; +import { usePostHog } from 'posthog-js/react'; import { AgentSettings, @@ -37,7 +38,25 @@ import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { - const { config, updateAndSaveConfig, loading } = useUserSystem(); + const { config, analyticsUserId, updateAndSaveConfig, loading } = + useUserSystem(); + const posthog = usePostHog(); + + // Handle opt-in/opt-out and user identification when config loads + useEffect(() => { + if (!posthog || !analyticsUserId) return; + + const userOptedIn = config?.analytics_enabled !== false; + + if (userOptedIn) { + posthog.opt_in_capturing(); + posthog.identify(analyticsUserId); + console.log('[Analytics] Analytics enabled and user identified'); + } else { + posthog.opt_out_capturing(); + console.log('[Analytics] Analytics disabled by user preference'); + } + }, [config?.analytics_enabled, analyticsUserId, posthog]); useEffect(() => { let cancelled = false; diff --git a/frontend/src/components/config-provider.tsx b/frontend/src/components/config-provider.tsx index 11eba7fe..50e13190 100644 --- a/frontend/src/components/config-provider.tsx +++ b/frontend/src/components/config-provider.tsx @@ -23,6 +23,7 @@ interface UserSystemState { environment: Environment | null; profiles: Record | null; capabilities: Record | null; + analyticsUserId: string | null; } interface UserSystemContextType { @@ -39,6 +40,7 @@ interface UserSystemContextType { environment: Environment | null; profiles: Record | null; capabilities: Record | null; + analyticsUserId: string | null; setEnvironment: (env: Environment | null) => void; setProfiles: (profiles: Record | null) => void; setCapabilities: (caps: Record | null) => void; @@ -71,6 +73,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { string, BaseAgentCapability[] > | null>(null); + const [analyticsUserId, setAnalyticsUserId] = useState(null); const [loading, setLoading] = useState(true); const [githubTokenInvalid, setGithubTokenInvalid] = useState(false); @@ -80,6 +83,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { const userSystemInfo: UserSystemInfo = await configApi.getConfig(); setConfig(userSystemInfo.config); setEnvironment(userSystemInfo.environment); + setAnalyticsUserId(userSystemInfo.analytics_user_id); setProfiles( userSystemInfo.executors as Record | null ); @@ -168,6 +172,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { const userSystemInfo: UserSystemInfo = await configApi.getConfig(); setConfig(userSystemInfo.config); setEnvironment(userSystemInfo.environment); + setAnalyticsUserId(userSystemInfo.analytics_user_id); setProfiles( userSystemInfo.executors as Record | null ); @@ -185,11 +190,12 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { // Memoize context value to prevent unnecessary re-renders const value = useMemo( () => ({ - system: { config, environment, profiles, capabilities }, + system: { config, environment, profiles, capabilities, analyticsUserId }, config, environment, profiles, capabilities, + analyticsUserId, updateConfig, saveConfig, updateAndSaveConfig, @@ -205,6 +211,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { environment, profiles, capabilities, + analyticsUserId, updateConfig, saveConfig, updateAndSaveConfig, diff --git a/frontend/src/components/panels/AttemptHeaderActions.tsx b/frontend/src/components/panels/AttemptHeaderActions.tsx index c2a4e183..2d61b992 100644 --- a/frontend/src/components/panels/AttemptHeaderActions.tsx +++ b/frontend/src/components/panels/AttemptHeaderActions.tsx @@ -11,6 +11,7 @@ import { import type { LayoutMode } from '../layout/TasksLayout'; import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; import { ActionsDropdown } from '../ui/ActionsDropdown'; +import { usePostHog } from 'posthog-js/react'; interface AttemptHeaderActionsProps { onClose: () => void; @@ -28,6 +29,8 @@ export const AttemptHeaderActions = ({ attempt, }: AttemptHeaderActionsProps) => { const { t } = useTranslation('tasks'); + const posthog = usePostHog(); + return ( <> {typeof mode !== 'undefined' && onModeChange && ( @@ -35,7 +38,34 @@ export const AttemptHeaderActions = ({ onModeChange((v as LayoutMode) || null)} + onValueChange={(v) => { + const newMode = (v as LayoutMode) || null; + + // Track view navigation + if (newMode === 'preview') { + posthog?.capture('preview_navigated', { + trigger: 'button', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } else if (newMode === 'diffs') { + posthog?.capture('diffs_navigated', { + trigger: 'button', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } else if (newMode === null) { + // Closing the view (clicked active button) + posthog?.capture('view_closed', { + trigger: 'button', + from_view: mode ?? 'attempt', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } + + onModeChange(newMode); + }} className="inline-flex gap-4" aria-label="Layout mode" > diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 37cf5f91..88b8622f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import NiceModal from '@ebay/nice-modal-react'; import i18n from './i18n'; +import posthog from 'posthog-js'; +import { PostHogProvider } from 'posthog-js/react'; // Import modal type definitions import './types/modals'; // Import and register modals @@ -85,6 +87,24 @@ Sentry.init({ }); Sentry.setTag('source', 'frontend'); +if ( + import.meta.env.VITE_POSTHOG_API_KEY && + import.meta.env.VITE_POSTHOG_API_ENDPOINT +) { + posthog.init(import.meta.env.VITE_POSTHOG_API_KEY, { + api_host: import.meta.env.VITE_POSTHOG_API_ENDPOINT, + capture_pageview: false, + capture_pageleave: true, + capture_performance: true, + autocapture: false, + opt_out_capturing_by_default: true, + }); +} else { + console.warn( + 'PostHog API key or endpoint not set. Analytics will be disabled.' + ); +} + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -97,15 +117,17 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - {i18n.t('common:states.error')}

} - showDialog - > - - - - {/* */} -
+ + {i18n.t('common:states.error')}

} + showDialog + > + + + + {/* */} +
+
); diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index a4d82c08..c1f327fc 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -11,6 +11,7 @@ import { openTaskForm } from '@/lib/openTaskForm'; import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal'; import { showcases } from '@/config/showcases'; import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger'; +import { usePostHog } from 'posthog-js/react'; import { useSearch } from '@/contexts/search-context'; import { useProject } from '@/contexts/project-context'; @@ -120,6 +121,7 @@ export function ProjectTasks() { const [searchParams, setSearchParams] = useSearchParams(); const isXL = useMediaQuery('(min-width: 1280px)'); const isMobile = !isXL; + const posthog = usePostHog(); const { projectId, @@ -388,6 +390,27 @@ export function ProjectTasks() { useKeyOpenDetails( () => { if (isPanelOpen) { + // Track keyboard shortcut before cycling view + const order: LayoutMode[] = [null, 'preview', 'diffs']; + const idx = order.indexOf(mode); + const next = order[(idx + 1) % order.length]; + + if (next === 'preview') { + posthog?.capture('preview_navigated', { + trigger: 'keyboard', + direction: 'forward', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } else if (next === 'diffs') { + posthog?.capture('diffs_navigated', { + trigger: 'keyboard', + direction: 'forward', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } + cycleViewForward(); } else if (selectedTask) { handleViewTaskDetails(selectedTask); @@ -400,6 +423,27 @@ export function ProjectTasks() { useKeyCycleViewBackward( () => { if (isPanelOpen) { + // Track keyboard shortcut before cycling view + const order: LayoutMode[] = [null, 'preview', 'diffs']; + const idx = order.indexOf(mode); + const next = order[(idx - 1 + order.length) % order.length]; + + if (next === 'preview') { + posthog?.capture('preview_navigated', { + trigger: 'keyboard', + direction: 'backward', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } else if (next === 'diffs') { + posthog?.capture('diffs_navigated', { + trigger: 'keyboard', + direction: 'backward', + timestamp: new Date().toISOString(), + source: 'frontend', + }); + } + cycleViewBackward(); } }, diff --git a/shared/types.ts b/shared/types.ts index 5edc4b88..a01b1026 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -58,7 +58,7 @@ export type CreateImage = { file_path: string, original_name: string, mime_type: export type ApiResponse = { success: boolean, data: T | null, error_data: E | null, message: string | null, }; -export type UserSystemInfo = { config: Config, environment: Environment, +export type UserSystemInfo = { config: Config, analytics_user_id: string, environment: Environment, /** * Capabilities supported per executor (e.g., { "CLAUDE_CODE": ["SESSION_FORK"] }) */