mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[eric] add PostHog analytics integration
This commit is contained in:
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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 = {}
|
||||||
@@ -25,3 +25,6 @@ class AppSettings(BaseModel):
|
|||||||
expand_new_chats_in_dashboard: bool = False
|
expand_new_chats_in_dashboard: bool = False
|
||||||
auto_reveal_sub_agents: bool = True
|
auto_reveal_sub_agents: bool = True
|
||||||
dev_mode: bool = False
|
dev_mode: bool = False
|
||||||
|
# Analytics: opted in by default, user can toggle off
|
||||||
|
analytics_opt_in: bool = True
|
||||||
|
installation_id: Optional[str] = None
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ def load_settings() -> AppSettings:
|
|||||||
return 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("")
|
@settings.router.get("")
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
return load_settings().model_dump()
|
return load_settings().model_dump()
|
||||||
|
|||||||
+2
-1
@@ -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.skill_registry.skill_registry import skill_registry
|
||||||
from backend.apps.outputs.outputs import outputs
|
from backend.apps.outputs.outputs import outputs
|
||||||
from backend.apps.dashboards.dashboards import dashboards
|
from backend.apps.dashboards.dashboards import dashboards
|
||||||
|
from backend.apps.analytics.analytics import analytics
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
import json
|
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 = main_app.app
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ pytest==8.3.4
|
|||||||
pytest-asyncio==0.25.2
|
pytest-asyncio==0.25.2
|
||||||
typeguard==4.4.2
|
typeguard==4.4.2
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
Pillow
|
Pillow
|
||||||
|
posthog
|
||||||
@@ -22,6 +22,8 @@ import Tools from './pages/Tools/Tools';
|
|||||||
import Modes from './pages/Modes/Modes';
|
import Modes from './pages/Modes/Modes';
|
||||||
import Views from './pages/Views/Views';
|
import Views from './pages/Views/Views';
|
||||||
import Customization from './pages/Customization/Customization';
|
import Customization from './pages/Customization/Customization';
|
||||||
|
import Analytics from './pages/Analytics/Analytics';
|
||||||
|
import AnalyticsOptIn from './components/AnalyticsOptIn';
|
||||||
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
||||||
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
||||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||||
@@ -228,8 +230,10 @@ const ThemedApp: React.FC = () => {
|
|||||||
<Route path="/modes" element={<Modes />} />
|
<Route path="/modes" element={<Modes />} />
|
||||||
<Route path="/apps" element={<Views />} />
|
<Route path="/apps" element={<Views />} />
|
||||||
<Route path="/apps/:id" element={<Views />} />
|
<Route path="/apps/:id" element={<Views />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<AnalyticsOptIn />
|
||||||
</UpdateListener>
|
</UpdateListener>
|
||||||
</SettingsLoader>
|
</SettingsLoader>
|
||||||
</ShortcutsProvider>
|
</ShortcutsProvider>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 24,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1400,
|
||||||
|
maxWidth: 480,
|
||||||
|
width: '90%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
bgcolor: c.bg.surface,
|
||||||
|
border: `1px solid ${c.border.medium}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: c.shadow.lg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: c.text.primary, fontSize: '0.9rem', fontWeight: 600, mb: 0.5 }}>
|
||||||
|
Help improve OpenSwarm
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }}>
|
||||||
|
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.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleChoice(false)}
|
||||||
|
sx={{
|
||||||
|
color: c.text.muted,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No thanks
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => handleChoice(true)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: c.accent.primary,
|
||||||
|
'&:hover': { bgcolor: c.accent.pressed },
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share anonymous data
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsOptIn;
|
||||||
@@ -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 (
|
||||||
|
<Box sx={{ height: '100%', overflow: 'auto', p: 3 }}>
|
||||||
|
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
|
||||||
|
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 600, mb: 3 }}>
|
||||||
|
Analytics
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{
|
||||||
|
p: 4,
|
||||||
|
bgcolor: c.bg.surface,
|
||||||
|
border: `1px solid ${c.border.subtle}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke={c.accent.primary} strokeWidth="1.5">
|
||||||
|
<path d="M3 3v18h18" />
|
||||||
|
<path d="M7 16l4-4 4 4 5-5" />
|
||||||
|
<circle cx="20" cy="7" r="1.5" fill={c.accent.primary} />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: c.text.primary, fontSize: '1.1rem', fontWeight: 600, mb: 1 }}>
|
||||||
|
Analytics powered by PostHog
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6, mb: 3, maxWidth: 500, mx: 'auto' }}>
|
||||||
|
Usage data is automatically collected — sessions, costs, tool usage, model distribution, and task categories.
|
||||||
|
All data is anonymous and can be disabled in Settings.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 3, textAlign: 'left' }}>
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<Box key={item.label} sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated }}>
|
||||||
|
<Typography sx={{ color: c.text.primary, fontSize: '0.82rem', fontWeight: 600, mb: 0.5 }}>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', lineHeight: 1.4 }}>
|
||||||
|
{item.desc}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
@@ -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<PixelChartProps> = ({
|
||||||
|
data,
|
||||||
|
palette = 'salmon',
|
||||||
|
height = 140,
|
||||||
|
pixelSize = 6,
|
||||||
|
formatValue,
|
||||||
|
glow = true,
|
||||||
|
showXLabels = true,
|
||||||
|
showYScale = true,
|
||||||
|
mode = 'bar',
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animRef = useRef(0);
|
||||||
|
const progressRef = useRef(0);
|
||||||
|
const hoverIdxRef = useRef(-1);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{showXLabels && data.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
|
||||||
|
{xLabels.map((xl) => (
|
||||||
|
<Typography
|
||||||
|
key={xl.idx}
|
||||||
|
sx={{
|
||||||
|
color: c.text.ghost,
|
||||||
|
fontSize: '0.58rem',
|
||||||
|
fontFamily: c.font.mono,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{xl.label}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{/* Tooltip */}
|
||||||
|
<Box
|
||||||
|
ref={tooltipRef}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.12s',
|
||||||
|
bgcolor: c.bg.inverse,
|
||||||
|
color: c.text.inverse,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontFamily: c.font.mono,
|
||||||
|
fontWeight: 500,
|
||||||
|
px: 1,
|
||||||
|
py: 0.35,
|
||||||
|
borderRadius: 0.75,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PixelChart;
|
||||||
@@ -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<string, number>;
|
||||||
|
modes_used: Record<string, number>;
|
||||||
|
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;
|
||||||
@@ -12,6 +12,7 @@ import outputsReducer from './outputsSlice';
|
|||||||
import dashboardLayoutReducer from './dashboardLayoutSlice';
|
import dashboardLayoutReducer from './dashboardLayoutSlice';
|
||||||
import dashboardsReducer from './dashboardsSlice';
|
import dashboardsReducer from './dashboardsSlice';
|
||||||
import updateReducer from './updateSlice';
|
import updateReducer from './updateSlice';
|
||||||
|
import analyticsReducer from './analyticsSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -28,6 +29,7 @@ export const store = configureStore({
|
|||||||
dashboardLayout: dashboardLayoutReducer,
|
dashboardLayout: dashboardLayoutReducer,
|
||||||
dashboards: dashboardsReducer,
|
dashboards: dashboardsReducer,
|
||||||
update: updateReducer,
|
update: updateReducer,
|
||||||
|
analytics: analyticsReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user