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"] })
*/