diff --git a/backend/apps/analytics/__init__.py b/backend/apps/analytics/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py
new file mode 100644
index 00000000..defef678
--- /dev/null
+++ b/backend/apps/analytics/analytics.py
@@ -0,0 +1,144 @@
+"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
+
+import json
+import logging
+import os
+import platform
+from collections import Counter
+from contextlib import asynccontextmanager
+
+from backend.config.Apps import SubApp
+from backend.config.paths import SESSIONS_DIR
+from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def analytics_lifespan():
+ init_collector()
+ logger.info("PostHog analytics initialised")
+
+ try:
+ from backend.apps.settings.settings import load_settings
+ settings = load_settings()
+
+ providers = []
+ if getattr(settings, "anthropic_api_key", None):
+ providers.append("anthropic")
+
+ record("app.opened", {
+ "os": platform.system(),
+ "platform": platform.platform(),
+ "provider_count": len(providers),
+ "providers": providers,
+ })
+
+ identify({
+ "providers_configured": providers,
+ "provider_count": len(providers),
+ })
+ except Exception as e:
+ logger.debug(f"Analytics startup event failed (non-critical): {e}")
+
+ yield
+
+ shutdown_collector()
+ logger.info("PostHog analytics shut down")
+
+
+analytics = SubApp("analytics", analytics_lifespan)
+
+
+def _load_all_sessions() -> list[dict]:
+ """Load all persisted session JSON files."""
+ results = []
+ if not os.path.exists(SESSIONS_DIR):
+ return results
+ for fname in os.listdir(SESSIONS_DIR):
+ if fname.endswith(".json"):
+ try:
+ with open(os.path.join(SESSIONS_DIR, fname)) as f:
+ results.append(json.load(f))
+ except Exception:
+ pass
+ return results
+
+
+@analytics.router.get("/usage-summary")
+async def usage_summary():
+ """Compute usage stats from persisted sessions for the Settings page."""
+ from backend.apps.agents.agent_manager import agent_manager
+
+ # Combine persisted + active sessions
+ sessions = _load_all_sessions()
+ for s in agent_manager.get_all_sessions():
+ sessions.append(s.model_dump(mode="json"))
+
+ total_sessions = len(sessions)
+ total_cost = sum(s.get("cost_usd", 0) for s in sessions)
+ total_messages = 0
+ total_tool_calls = 0
+ total_duration = 0.0
+ model_counts: Counter = Counter()
+ provider_counts: Counter = Counter()
+ tool_counts: Counter = Counter()
+ status_counts: Counter = Counter()
+
+ for s in sessions:
+ messages = s.get("messages", [])
+ user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
+ tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
+ total_messages += len(user_msgs)
+ total_tool_calls += len(tool_msgs)
+
+ model_counts[s.get("model", "unknown")] += 1
+ provider_counts[s.get("provider", "anthropic")] += 1
+ status_counts[s.get("status", "unknown")] += 1
+
+ # Duration
+ created = s.get("created_at")
+ closed = s.get("closed_at")
+ if created and closed:
+ try:
+ from datetime import datetime
+ fmt = "%Y-%m-%dT%H:%M:%S"
+ c_str = created[:19]
+ cl_str = closed[:19]
+ dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
+ if dur > 0:
+ total_duration += dur
+ except Exception:
+ pass
+
+ # Count individual tools
+ for m in tool_msgs:
+ content = m.get("content", {})
+ if isinstance(content, dict):
+ tool_name = content.get("tool", "")
+ if tool_name:
+ tool_counts[tool_name] += 1
+
+ avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
+ avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
+ completed = status_counts.get("completed", 0)
+ completion_rate = completed / total_sessions if total_sessions > 0 else 0
+
+ return {
+ "total_sessions": total_sessions,
+ "total_cost_usd": round(total_cost, 4),
+ "total_messages": total_messages,
+ "total_tool_calls": total_tool_calls,
+ "avg_duration_seconds": round(avg_duration, 1),
+ "avg_cost_per_session": round(avg_cost, 4),
+ "completion_rate": round(completion_rate, 3),
+ "models_used": dict(model_counts.most_common(10)),
+ "providers_used": dict(provider_counts.most_common(10)),
+ "top_tools": dict(tool_counts.most_common(15)),
+ "status_breakdown": dict(status_counts),
+ }
+
+
+@analytics.router.get("/status")
+async def analytics_status():
+ return {"status": "posthog", "enabled": True}
diff --git a/backend/apps/analytics/collector.py b/backend/apps/analytics/collector.py
new file mode 100644
index 00000000..dddcedc5
--- /dev/null
+++ b/backend/apps/analytics/collector.py
@@ -0,0 +1,123 @@
+"""PostHog-only analytics collector.
+
+All events go directly to PostHog. No local SQLite storage.
+
+Usage from any module:
+ from backend.apps.analytics.collector import record
+ record("session.started", {"model": "opus"}, session_id="abc123")
+"""
+
+import logging
+import platform
+from uuid import uuid4
+
+from posthog import Posthog
+
+logger = logging.getLogger(__name__)
+
+POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
+POSTHOG_HOST = "https://us.i.posthog.com"
+
+_posthog: Posthog | None = None
+_installation_id: str | None = None
+
+
+def init():
+ """Initialise PostHog. Called once at app startup."""
+ global _posthog
+ if _posthog is None:
+ _posthog = Posthog(
+ project_api_key=POSTHOG_API_KEY,
+ host=POSTHOG_HOST,
+ )
+ return _posthog
+
+
+def shutdown():
+ """Flush and close. Called at app shutdown."""
+ global _posthog
+ if _posthog:
+ try:
+ _posthog.shutdown()
+ except Exception:
+ pass
+ _posthog = None
+
+
+def _get_installation_id() -> str:
+ """Get or create a stable anonymous installation ID."""
+ global _installation_id
+ if _installation_id:
+ return _installation_id
+ try:
+ from backend.apps.settings.settings import load_settings, _save_settings
+ settings = load_settings()
+ iid = getattr(settings, "installation_id", None)
+ if not iid:
+ iid = uuid4().hex
+ settings.installation_id = iid
+ _save_settings(settings)
+ _installation_id = iid
+ except Exception:
+ _installation_id = uuid4().hex
+ return _installation_id
+
+
+def _is_opted_in() -> bool:
+ """Check if user has opted in to analytics."""
+ try:
+ from backend.apps.settings.settings import load_settings
+ return getattr(load_settings(), "analytics_opt_in", True)
+ except Exception:
+ return True
+
+
+def record(
+ event_type: str,
+ properties: dict | None = None,
+ session_id: str | None = None,
+ dashboard_id: str | None = None,
+):
+ """Record an analytics event to PostHog."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ props = {**(properties or {})}
+ if session_id:
+ props["session_id"] = session_id
+ if dashboard_id:
+ props["dashboard_id"] = dashboard_id
+ props["os"] = platform.system()
+ props["platform"] = platform.platform()
+
+ try:
+ _posthog.capture(
+ event_type,
+ distinct_id=_get_installation_id(),
+ properties=props,
+ )
+ except Exception as e:
+ logger.debug(f"PostHog capture failed (non-critical): {e}")
+
+
+def identify(extra_properties: dict | None = None):
+ """Identify the current installation with properties."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ try:
+ _posthog.identify(
+ _get_installation_id(),
+ properties={
+ "os": platform.system(),
+ "platform": platform.platform(),
+ **(extra_properties or {}),
+ },
+ )
+ except Exception as e:
+ logger.debug(f"PostHog identify failed (non-critical): {e}")
+
+
+def get_collector():
+ """Backward compat — returns None since we no longer have a local collector."""
+ return None
diff --git a/backend/apps/analytics/models.py b/backend/apps/analytics/models.py
new file mode 100644
index 00000000..a8696dde
--- /dev/null
+++ b/backend/apps/analytics/models.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class AnalyticsEvent(BaseModel):
+ id: Optional[int] = None
+ timestamp: str
+ event_type: str
+ properties: dict
+ session_id: Optional[str] = None
+ dashboard_id: Optional[str] = None
+
+
+class UsageSummary(BaseModel):
+ total_sessions: int = 0
+ total_cost_usd: float = 0.0
+ total_messages: int = 0
+ total_tool_calls: int = 0
+ avg_session_duration_seconds: float = 0.0
+ session_completion_rate: float = 0.0
+ approval_rate: float = 0.0
+ models_used: dict[str, int] = {}
+ modes_used: dict[str, int] = {}
+ top_tools: list[list] = []
+
+
+class TimeSeriesPoint(BaseModel):
+ date: str
+ value: float
+
+
+class ExportPayload(BaseModel):
+ export_version: str = "1.0"
+ exported_at: str = ""
+ app_version: str = "unknown"
+ period: dict = {}
+ summary: dict = {}
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index e2e3061f..172e8337 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -25,3 +25,6 @@ class AppSettings(BaseModel):
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
+ # Analytics: opted in by default, user can toggle off
+ analytics_opt_in: bool = True
+ installation_id: Optional[str] = None
diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py
index e590d9a4..4ddfd1f8 100644
--- a/backend/apps/settings/settings.py
+++ b/backend/apps/settings/settings.py
@@ -38,6 +38,13 @@ def load_settings() -> AppSettings:
return AppSettings()
+def _save_settings(settings_obj: AppSettings):
+ """Persist settings to JSON file."""
+ os.makedirs(DATA_DIR, exist_ok=True)
+ with open(SETTINGS_FILE, "w") as f:
+ json.dump(settings_obj.model_dump(), f, indent=2)
+
+
@settings.router.get("")
async def get_settings():
return load_settings().model_dump()
diff --git a/backend/main.py b/backend/main.py
index c3d8736a..9dbfd4b7 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -19,11 +19,12 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
+from backend.apps.analytics.analytics import analytics
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
-main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards])
+main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics])
app = main_app.app
app.add_middleware(
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 8ef474e6..9cc94efe 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,4 +9,5 @@ pytest==8.3.4
pytest-asyncio==0.25.2
typeguard==4.4.2
python-dotenv==1.1.1
-Pillow
\ No newline at end of file
+Pillow
+posthog
\ No newline at end of file
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 97920562..e1ac50dc 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -22,6 +22,8 @@ import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
+import Analytics from './pages/Analytics/Analytics';
+import AnalyticsOptIn from './components/AnalyticsOptIn';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -228,8 +230,10 @@ const ThemedApp: React.FC = () => {
} />
} />
} />
+ } />
+
diff --git a/frontend/src/app/components/AnalyticsOptIn.tsx b/frontend/src/app/components/AnalyticsOptIn.tsx
new file mode 100644
index 00000000..b96701dd
--- /dev/null
+++ b/frontend/src/app/components/AnalyticsOptIn.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Paper from '@mui/material/Paper';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import { updateSettings } from '@/shared/state/settingsSlice';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const AnalyticsOptIn: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const settings = useAppSelector((s) => s.settings.data);
+ const loaded = useAppSelector((s) => s.settings.loaded);
+
+ if (!loaded || settings.analytics_opt_in !== null) return null;
+
+ const handleChoice = (optIn: boolean) => {
+ dispatch(updateSettings({ ...settings, analytics_opt_in: optIn }));
+ };
+
+ return (
+
+
+
+ Help improve OpenSwarm
+
+
+ Share anonymous usage statistics like session counts, feature usage, and model preferences.
+ No conversations, file paths, or personal information — ever.
+ You can change this anytime in Settings.
+
+
+
+
+
+
+
+ );
+};
+
+export default AnalyticsOptIn;
diff --git a/frontend/src/app/pages/Analytics/Analytics.tsx b/frontend/src/app/pages/Analytics/Analytics.tsx
new file mode 100644
index 00000000..1c10703e
--- /dev/null
+++ b/frontend/src/app/pages/Analytics/Analytics.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Paper from '@mui/material/Paper';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const Analytics: React.FC = () => {
+ const c = useClaudeTokens();
+
+ return (
+
+
+
+ Analytics
+
+
+
+
+
+
+
+ Analytics powered by PostHog
+
+
+ Usage data is automatically collected — sessions, costs, tool usage, model distribution, and task categories.
+ All data is anonymous and can be disabled in Settings.
+
+
+
+ {[
+ { label: 'Sessions & Usage', desc: 'How often agents are launched, session duration, completion rates' },
+ { label: 'Cost Tracking', desc: 'Spend by model, provider, and time period' },
+ { label: 'Task Categories', desc: 'What users do — coding, email, research, social, browsing' },
+ { label: 'Model Distribution', desc: 'Which models and providers are most popular' },
+ { label: 'Tool Usage', desc: 'Most used MCP tools, execution times, approval rates' },
+ { label: 'Retention & Funnels', desc: 'User engagement, feature adoption, onboarding flow' },
+ ].map((item) => (
+
+
+ {item.label}
+
+
+ {item.desc}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Analytics;
diff --git a/frontend/src/app/pages/Analytics/PixelChart.tsx b/frontend/src/app/pages/Analytics/PixelChart.tsx
new file mode 100644
index 00000000..f889517b
--- /dev/null
+++ b/frontend/src/app/pages/Analytics/PixelChart.tsx
@@ -0,0 +1,402 @@
+import React, { useRef, useEffect, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const PALETTES = {
+ salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
+ blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
+ coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
+ green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
+ purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
+} as const;
+
+type PaletteKey = keyof typeof PALETTES;
+
+interface PixelChartProps {
+ data: { label: string; value: number }[];
+ palette?: PaletteKey;
+ height?: number;
+ pixelSize?: number;
+ formatValue?: (v: number) => string;
+ glow?: boolean;
+ showXLabels?: boolean;
+ showYScale?: boolean;
+ mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars
+}
+
+const PixelChart: React.FC = ({
+ data,
+ palette = 'salmon',
+ height = 140,
+ pixelSize = 6,
+ formatValue,
+ glow = true,
+ showXLabels = true,
+ showYScale = true,
+ mode = 'bar',
+}) => {
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const animRef = useRef(0);
+ const progressRef = useRef(0);
+ const hoverIdxRef = useRef(-1);
+ const tooltipRef = useRef(null);
+ const c = useClaudeTokens();
+ const colors = PALETTES[palette];
+
+ const maxVal = Math.max(...data.map((d) => d.value), 0.001);
+
+ // Compute nice Y-axis ticks
+ const yTicks = (() => {
+ if (maxVal <= 0) return [0];
+ const rawStep = maxVal / 3;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ const normalised = rawStep / magnitude;
+ let niceStep: number;
+ if (normalised <= 1) niceStep = magnitude;
+ else if (normalised <= 2) niceStep = 2 * magnitude;
+ else if (normalised <= 5) niceStep = 5 * magnitude;
+ else niceStep = 10 * magnitude;
+ const ticks: number[] = [];
+ for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
+ ticks.push(v);
+ }
+ if (ticks.length < 2) ticks.push(niceStep);
+ return ticks;
+ })();
+
+ // X-axis labels: show first, last, and up to 3 evenly spaced
+ const xLabels = (() => {
+ if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
+ if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
+ const result: { idx: number; label: string }[] = [];
+ result.push({ idx: 0, label: data[0].label });
+ const step = Math.floor(data.length / 4);
+ for (let i = 1; i <= 3; i++) {
+ const idx = Math.min(i * step, data.length - 2);
+ if (idx > 0 && idx < data.length - 1) {
+ result.push({ idx, label: data[idx].label });
+ }
+ }
+ result.push({ idx: data.length - 1, label: data[data.length - 1].label });
+ return result;
+ })();
+
+ const Y_LABEL_WIDTH = showYScale ? 80 : 0;
+
+ const draw = useCallback(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container || data.length === 0) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const totalW = container.clientWidth;
+ const chartW = totalW - Y_LABEL_WIDTH;
+ const h = height;
+ canvas.width = totalW * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${totalW}px`;
+ canvas.style.height = `${h}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ ctx.scale(dpr, dpr);
+
+ const px = pixelSize;
+ const gridCols = Math.floor(chartW / px);
+ const gridRows = Math.floor(h / px);
+ const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
+
+ ctx.clearRect(0, 0, totalW, h);
+
+ // Y-axis labels and horizontal grid lines
+ if (showYScale) {
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+
+ for (const tick of yTicks) {
+ const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
+ const yPx = h - yNorm * (h - px);
+
+ // Grid line
+ ctx.strokeStyle = c.border.subtle;
+ ctx.lineWidth = 0.5;
+ ctx.setLineDash([2, 4]);
+ ctx.beginPath();
+ ctx.moveTo(Y_LABEL_WIDTH, yPx);
+ ctx.lineTo(totalW, yPx);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Label
+ const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
+ ctx.fillStyle = c.text.ghost;
+ ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx);
+ }
+ }
+
+ // Subtle grid dots in chart area
+ ctx.fillStyle = c.border.subtle;
+ for (let gy = 0; gy < gridRows; gy += 5) {
+ for (let gx = 0; gx < gridCols; gx += 5) {
+ ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1);
+ }
+ }
+
+ const progress = Math.min(progressRef.current, 1);
+ const hoverIdx = hoverIdxRef.current;
+
+ if (mode === 'area') {
+ // -- Area / line chart mode --
+ const usableH = h - px * 2;
+ const points: { x: number; y: number }[] = [];
+
+ for (let i = 0; i < data.length; i++) {
+ const val = data[i].value;
+ const norm = effectiveMax > 0 ? val / effectiveMax : 0;
+ const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW;
+ const y = h - px - norm * usableH * progress;
+ points.push({ x, y });
+ }
+
+ if (points.length > 0) {
+ // Filled area with gradient
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
+ gradient.addColorStop(0, colors[colors.length - 1] + '60');
+ gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
+ gradient.addColorStop(1, colors[0] + '08');
+
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, h);
+ for (let i = 0; i < points.length; i++) {
+ if (i === 0) {
+ ctx.lineTo(points[i].x, points[i].y);
+ } else {
+ const prev = points[i - 1];
+ const curr = points[i];
+ const cpx = (prev.x + curr.x) / 2;
+ ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
+ }
+ }
+ ctx.lineTo(points[points.length - 1].x, h);
+ ctx.closePath();
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ // Line on top
+ ctx.beginPath();
+ for (let i = 0; i < points.length; i++) {
+ if (i === 0) {
+ ctx.moveTo(points[i].x, points[i].y);
+ } else {
+ const prev = points[i - 1];
+ const curr = points[i];
+ const cpx = (prev.x + curr.x) / 2;
+ ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
+ }
+ }
+ ctx.strokeStyle = colors[colors.length - 1];
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Glow on line
+ if (glow) {
+ ctx.shadowColor = colors[colors.length - 1];
+ ctx.shadowBlur = 8;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+
+ // Data point dots
+ for (let i = 0; i < points.length; i++) {
+ if (data[i].value > 0) {
+ const isHov = i === hoverIdx;
+ ctx.beginPath();
+ ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
+ ctx.fill();
+ if (isHov) {
+ ctx.strokeStyle = colors[colors.length - 1];
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+ }
+ }
+
+ // Pixel scatter in the filled area for the pixel art feel
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+ const steps = Math.ceil((p2.x - p1.x) / px);
+ for (let s = 0; s < steps; s++) {
+ const t = s / steps;
+ const x = p1.x + t * (p2.x - p1.x);
+ const lineY = p1.y + t * (p2.y - p1.y);
+ for (let py = lineY + px * 2; py < h - px; py += px * 2) {
+ if (Math.random() > 0.65) {
+ const depth = (py - lineY) / (h - lineY);
+ const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
+ ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
+ ctx.fillStyle = colors[ci];
+ ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
+ }
+ }
+ }
+ }
+ ctx.globalAlpha = 1;
+ }
+ } else {
+ // -- Bar chart mode (original) --
+ const barSlots = data.length;
+ const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
+ const barW = Math.max(1, totalBarPx - 1);
+
+ for (let i = 0; i < data.length; i++) {
+ const val = data[i].value;
+ const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
+ const usableRows = gridRows - 2;
+ const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
+ const barH = Math.round(targetH * progress);
+ const barX = i * totalBarPx;
+ const isHovered = i === hoverIdx;
+
+ for (let row = 0; row < barH; row++) {
+ const y = gridRows - 1 - row;
+ const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
+ const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
+
+ for (let col = 0; col < barW; col++) {
+ ctx.fillStyle = baseColor;
+ ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1);
+ }
+ }
+
+ if (glow && barH > 0) {
+ const topY = (gridRows - 1 - barH + 1) * px;
+ ctx.shadowColor = colors[colors.length - 1];
+ ctx.shadowBlur = 6;
+ ctx.fillStyle = colors[colors.length - 1];
+ for (let col = 0; col < barW; col++) {
+ ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1);
+ }
+ ctx.shadowBlur = 0;
+ }
+ }
+ }
+ }, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
+
+ useEffect(() => {
+ progressRef.current = 0;
+ let start: number | null = null;
+ const animate = (ts: number) => {
+ if (!start) start = ts;
+ progressRef.current = Math.min(1, (ts - start) / 600);
+ draw();
+ if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
+ };
+ animRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(animRef.current);
+ }, [data, draw]);
+
+ useEffect(() => {
+ const handleResize = () => draw();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [draw]);
+
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ const tooltip = tooltipRef.current;
+ if (!canvas || !tooltip || data.length === 0) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
+ if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
+
+ const chartW = rect.width - Y_LABEL_WIDTH;
+ const gridCols = Math.floor(chartW / pixelSize);
+ const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
+ const idx = Math.floor(mx / (totalBarPx * pixelSize));
+
+ if (idx >= 0 && idx < data.length) {
+ hoverIdxRef.current = idx;
+ const d = data[idx];
+ const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
+ tooltip.textContent = `${d.label}: ${valStr}`;
+ tooltip.style.opacity = '1';
+ tooltip.style.left = `${e.clientX - rect.left}px`;
+ tooltip.style.top = `${e.clientY - rect.top - 28}px`;
+ } else {
+ hoverIdxRef.current = -1;
+ tooltip.style.opacity = '0';
+ }
+ draw();
+ },
+ [data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
+ );
+
+ const handleMouseLeave = useCallback(() => {
+ hoverIdxRef.current = -1;
+ if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
+ draw();
+ }, [draw]);
+
+ return (
+
+
+ {/* X-axis labels */}
+ {showXLabels && data.length > 0 && (
+
+ {xLabels.map((xl) => (
+
+ {xl.label}
+
+ ))}
+
+ )}
+ {/* Tooltip */}
+
+
+ );
+};
+
+export default PixelChart;
diff --git a/frontend/src/shared/state/analyticsSlice.ts b/frontend/src/shared/state/analyticsSlice.ts
new file mode 100644
index 00000000..8de6ca32
--- /dev/null
+++ b/frontend/src/shared/state/analyticsSlice.ts
@@ -0,0 +1,235 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+import { API_BASE } from '@/shared/config';
+
+const ANALYTICS_API = `${API_BASE}/analytics`;
+
+export interface AnalyticsSummary {
+ total_sessions: number;
+ total_cost_usd: number;
+ total_messages: number;
+ total_tool_calls: number;
+ avg_session_duration_seconds: number;
+ session_completion_rate: number;
+ approval_rate: number;
+ models_used: Record;
+ modes_used: Record;
+ top_tools: [string, number][];
+}
+
+export interface UsagePoint {
+ date: string;
+ sessions: number;
+ cost: number;
+}
+
+export interface CostPoint {
+ date: string;
+ cost: number;
+}
+
+export interface ToolRank {
+ tool: string;
+ count: number;
+}
+
+export interface ApprovalStats {
+ allow: number;
+ deny: number;
+ total: number;
+ rate: number;
+ avg_latency_ms: number;
+}
+
+export interface SessionStats {
+ completed: number;
+ stopped: number;
+ error: number;
+ total: number;
+ completion_rate: number;
+ avg_duration_seconds: number;
+}
+
+export interface HourlyPoint {
+ hour: number;
+ count: number;
+}
+
+export interface DurationBucket {
+ label: string;
+ count: number;
+}
+
+export interface CostByModel {
+ model: string;
+ cost: number;
+ sessions: number;
+}
+
+export interface CumulativeCostPoint {
+ date: string;
+ cumulative: number;
+ daily: number;
+}
+
+export interface ToolDuration {
+ tool: string;
+ calls: number;
+ avg_ms: number;
+ max_ms: number;
+}
+
+export interface SessionCost {
+ timestamp: string;
+ model: string;
+ cost: number;
+ duration: number;
+ messages: number;
+}
+
+interface AnalyticsState {
+ summary: AnalyticsSummary | null;
+ usage: UsagePoint[];
+ cost: CostPoint[];
+ tools: ToolRank[];
+ approvals: ApprovalStats | null;
+ sessionStats: SessionStats | null;
+ hourly: HourlyPoint[];
+ durationDist: DurationBucket[];
+ costByModel: CostByModel[];
+ cumulativeCost: CumulativeCostPoint[];
+ toolDurations: ToolDuration[];
+ sessionCosts: SessionCost[];
+ exportPreview: any | null;
+ loading: boolean;
+}
+
+const initialState: AnalyticsState = {
+ summary: null,
+ usage: [],
+ cost: [],
+ tools: [],
+ approvals: null,
+ sessionStats: null,
+ hourly: [],
+ durationDist: [],
+ costByModel: [],
+ cumulativeCost: [],
+ toolDurations: [],
+ sessionCosts: [],
+ exportPreview: null,
+ loading: false,
+};
+
+export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
+ const res = await fetch(`${ANALYTICS_API}/summary`);
+ return (await res.json()) as AnalyticsSummary;
+});
+
+export const fetchUsage = createAsyncThunk(
+ 'analytics/fetchUsage',
+ async ({ period, range }: { period: string; range: number }) => {
+ const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`);
+ const data = await res.json();
+ return data.data as UsagePoint[];
+ },
+);
+
+export const fetchCost = createAsyncThunk(
+ 'analytics/fetchCost',
+ async ({ period, range }: { period: string; range: number }) => {
+ const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`);
+ const data = await res.json();
+ return data.data as CostPoint[];
+ },
+);
+
+export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => {
+ const res = await fetch(`${ANALYTICS_API}/tools?limit=20`);
+ const data = await res.json();
+ return data.data as ToolRank[];
+});
+
+export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => {
+ const res = await fetch(`${ANALYTICS_API}/approvals`);
+ return (await res.json()) as ApprovalStats;
+});
+
+export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => {
+ const res = await fetch(`${ANALYTICS_API}/sessions-stats`);
+ return (await res.json()) as SessionStats;
+});
+
+export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => {
+ const res = await fetch(`${ANALYTICS_API}/hourly-activity`);
+ const data = await res.json();
+ return data.data as HourlyPoint[];
+});
+
+export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => {
+ const res = await fetch(`${ANALYTICS_API}/duration-distribution`);
+ const data = await res.json();
+ return data.data as DurationBucket[];
+});
+
+export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => {
+ const res = await fetch(`${ANALYTICS_API}/cost-by-model`);
+ const data = await res.json();
+ return data.data as CostByModel[];
+});
+
+export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => {
+ const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`);
+ const data = await res.json();
+ return data.data as CumulativeCostPoint[];
+});
+
+export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => {
+ const res = await fetch(`${ANALYTICS_API}/tool-durations`);
+ const data = await res.json();
+ return data.data as ToolDuration[];
+});
+
+export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => {
+ const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`);
+ const data = await res.json();
+ return data.data as SessionCost[];
+});
+
+export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => {
+ const res = await fetch(`${ANALYTICS_API}/export/preview`);
+ return await res.json();
+});
+
+export const doExport = createAsyncThunk('analytics/doExport', async () => {
+ const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' });
+ return await res.json();
+});
+
+const analyticsSlice = createSlice({
+ name: 'analytics',
+ initialState,
+ reducers: {},
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
+ .addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
+ state.loading = false;
+ state.summary = action.payload;
+ })
+ .addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
+ .addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; })
+ .addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; })
+ .addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; })
+ .addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; })
+ .addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; })
+ .addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; })
+ .addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; })
+ .addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; })
+ .addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; })
+ .addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; })
+ .addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; })
+ .addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; });
+ },
+});
+
+export default analyticsSlice.reducer;
diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts
index a1424e45..fceb33c9 100644
--- a/frontend/src/shared/state/store.ts
+++ b/frontend/src/shared/state/store.ts
@@ -12,6 +12,7 @@ import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
+import analyticsReducer from './analyticsSlice';
export const store = configureStore({
reducer: {
@@ -28,6 +29,7 @@ export const store = configureStore({
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,
update: updateReducer,
+ analytics: analyticsReducer,
},
});