feat: add cloudflare analytics dashboard script (#473)

terminal-based vanity metrics dashboard using plotext for visualization.

features:
- zone stats: requests, pageviews, unique visitors, bandwidth, cache ratio
- daily requests bar chart (cyan)
- pageviews vs uniques line chart (green/magenta)
- CLI options: --days/-d for time window, --no-cache to force refresh
- automatic daily caching (~/.cache/plyr-analytics/)
- pydantic-settings for .env integration (CF_API_TOKEN, CF_ZONE_ID)

usage:
uv run scripts/cf_analytics.py # last 7 days
uv run scripts/cf_analytics.py -d 30 # last 30 days
uv run scripts/cf_analytics.py --no-cache

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub bc0689bd d7c548ff

Changed files
+353
scripts
+353
scripts/cf_analytics.py
··· 1 + #!/usr/bin/env python3 2 + # /// script 3 + # requires-python = ">=3.11" 4 + # dependencies = ["httpx", "rich", "pydantic-settings", "plotext", "typer"] 5 + # /// 6 + """cloudflare analytics dashboard - vanity metrics at your fingertips 7 + 8 + usage: 9 + uv run scripts/cf_analytics.py # last 7 days (default) 10 + uv run scripts/cf_analytics.py --days 30 # last 30 days 11 + uv run scripts/cf_analytics.py -d 14 # last 14 days 12 + uv run scripts/cf_analytics.py --no-cache # force refresh 13 + """ 14 + 15 + import hashlib 16 + import json 17 + from datetime import datetime, timedelta 18 + from pathlib import Path 19 + from typing import Any 20 + 21 + import httpx 22 + import plotext as plt 23 + import typer 24 + from pydantic_settings import BaseSettings, SettingsConfigDict 25 + from rich.console import Console 26 + from rich.panel import Panel 27 + from rich.table import Table 28 + 29 + 30 + class Settings(BaseSettings): 31 + model_config = SettingsConfigDict(env_file=".env", extra="ignore") 32 + 33 + cf_api_token: str 34 + cf_zone_id: str | None = None 35 + cf_account_id: str | None = None 36 + 37 + 38 + settings = Settings() 39 + 40 + GRAPHQL_ENDPOINT = "https://api.cloudflare.com/client/v4/graphql" 41 + CACHE_DIR = Path.home() / ".cache" / "plyr-analytics" 42 + 43 + console = Console() 44 + app = typer.Typer(add_completion=False) 45 + 46 + 47 + def get_cache_path(query_type: str, days: int) -> Path: 48 + """get cache file path for a query""" 49 + today = datetime.now().strftime("%Y-%m-%d") 50 + key = f"{query_type}-{days}-{today}" 51 + return CACHE_DIR / f"{hashlib.md5(key.encode()).hexdigest()[:12]}.json" 52 + 53 + 54 + def load_cache(query_type: str, days: int) -> dict[str, Any] | None: 55 + """load cached data if valid (same calendar day)""" 56 + cache_path = get_cache_path(query_type, days) 57 + if cache_path.exists(): 58 + try: 59 + data = json.loads(cache_path.read_text()) 60 + data.pop("_cached_date", None) # strip metadata before returning 61 + console.print(f"[dim]using cached {query_type} data[/]") 62 + return data 63 + except (json.JSONDecodeError, KeyError): 64 + cache_path.unlink(missing_ok=True) 65 + return None 66 + 67 + 68 + def save_cache(query_type: str, days: int, data: dict[str, Any]) -> None: 69 + """save data to cache""" 70 + CACHE_DIR.mkdir(parents=True, exist_ok=True) 71 + cache_path = get_cache_path(query_type, days) 72 + cached = {"_cached_date": datetime.now().strftime("%Y-%m-%d"), **data} 73 + cache_path.write_text(json.dumps(cached)) 74 + 75 + 76 + def clear_old_cache() -> None: 77 + """remove cache files from previous days""" 78 + if not CACHE_DIR.exists(): 79 + return 80 + today = datetime.now().strftime("%Y-%m-%d") 81 + for f in CACHE_DIR.glob("*.json"): 82 + try: 83 + data = json.loads(f.read_text()) 84 + if data.get("_cached_date") != today: 85 + f.unlink() 86 + except (json.JSONDecodeError, KeyError): 87 + f.unlink() 88 + 89 + 90 + def query_cf(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: 91 + """hit the cloudflare graphql api""" 92 + headers = { 93 + "Authorization": f"Bearer {settings.cf_api_token}", 94 + "Content-Type": "application/json", 95 + } 96 + payload: dict[str, Any] = {"query": query} 97 + if variables: 98 + payload["variables"] = variables 99 + 100 + resp = httpx.post(GRAPHQL_ENDPOINT, json=payload, headers=headers, timeout=30) 101 + resp.raise_for_status() 102 + data = resp.json() 103 + 104 + if data.get("errors"): 105 + raise Exception(f"GraphQL errors: {data['errors']}") 106 + 107 + return data["data"] 108 + 109 + 110 + def get_zone_analytics(days: int = 7, use_cache: bool = True) -> dict[str, Any]: 111 + """get HTTP request analytics for the zone""" 112 + if use_cache and (cached := load_cache("zone", days)): 113 + return cached 114 + 115 + start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") 116 + 117 + query = """ 118 + query ZoneAnalytics($zoneTag: String!, $since: Date!) { 119 + viewer { 120 + zones(filter: {zoneTag: $zoneTag}) { 121 + httpRequests1dGroups( 122 + filter: {date_gt: $since} 123 + orderBy: [date_ASC] 124 + limit: 100 125 + ) { 126 + dimensions { 127 + date 128 + } 129 + sum { 130 + requests 131 + pageViews 132 + bytes 133 + cachedBytes 134 + threats 135 + } 136 + uniq { 137 + uniques 138 + } 139 + } 140 + } 141 + } 142 + } 143 + """ 144 + 145 + result = query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date}) 146 + save_cache("zone", days, result) 147 + return result 148 + 149 + 150 + def get_web_analytics(days: int = 7, use_cache: bool = True) -> dict[str, Any]: 151 + """get RUM/web analytics (actual browser visits)""" 152 + if use_cache and (cached := load_cache("rum", days)): 153 + return cached 154 + 155 + since = (datetime.now() - timedelta(days=days)).isoformat() + "Z" 156 + 157 + query = """ 158 + query WebAnalytics($accountTag: String!, $since: Time!) { 159 + viewer { 160 + accounts(filter: {accountTag: $accountTag}) { 161 + rumPageloadEventsAdaptiveGroups( 162 + filter: {datetime_gt: $since} 163 + limit: 5000 164 + ) { 165 + count 166 + sum { 167 + visits 168 + } 169 + dimensions { 170 + date: date 171 + } 172 + } 173 + } 174 + } 175 + } 176 + """ 177 + 178 + result = query_cf(query, {"accountTag": settings.cf_account_id, "since": since}) 179 + save_cache("rum", days, result) 180 + return result 181 + 182 + 183 + def get_top_paths(days: int = 7) -> dict[str, Any]: 184 + """get top pages by requests""" 185 + start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") 186 + 187 + query = """ 188 + query TopPaths($zoneTag: String!, $since: Date!) { 189 + viewer { 190 + zones(filter: {zoneTag: $zoneTag}) { 191 + httpRequests1dGroups( 192 + filter: {date_gt: $since} 193 + orderBy: [sum_requests_DESC] 194 + limit: 10 195 + ) { 196 + sum { 197 + requests 198 + } 199 + dimensions { 200 + clientRequestPath 201 + } 202 + } 203 + } 204 + } 205 + } 206 + """ 207 + 208 + return query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date}) 209 + 210 + 211 + def get_countries(days: int = 7) -> dict[str, Any]: 212 + """get requests by country""" 213 + start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") 214 + 215 + query = """ 216 + query Countries($zoneTag: String!, $since: Date!) { 217 + viewer { 218 + zones(filter: {zoneTag: $zoneTag}) { 219 + httpRequests1dGroups( 220 + filter: {date_gt: $since} 221 + orderBy: [sum_requests_DESC] 222 + limit: 10 223 + ) { 224 + sum { 225 + requests 226 + } 227 + dimensions { 228 + clientCountryName 229 + } 230 + } 231 + } 232 + } 233 + } 234 + """ 235 + 236 + return query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date}) 237 + 238 + 239 + def format_bytes(b: int) -> str: 240 + """human readable bytes""" 241 + for unit in ["B", "KB", "MB", "GB", "TB"]: 242 + if b < 1024: 243 + return f"{b:.1f} {unit}" 244 + b /= 1024 245 + return f"{b:.1f} PB" 246 + 247 + 248 + def display_dashboard(days: int = 7, use_cache: bool = True) -> None: 249 + """render the vanity dashboard""" 250 + clear_old_cache() 251 + console.print(f"\n[bold cyan]plyr.fm analytics[/] - last {days} days\n") 252 + 253 + # zone analytics 254 + if settings.cf_zone_id: 255 + try: 256 + data = get_zone_analytics(days, use_cache=use_cache) 257 + groups = data["viewer"]["zones"][0]["httpRequests1dGroups"] 258 + 259 + total_requests = sum(g["sum"]["requests"] for g in groups) 260 + total_pageviews = sum(g["sum"]["pageViews"] for g in groups) 261 + total_bytes = sum(g["sum"]["bytes"] for g in groups) 262 + total_cached = sum(g["sum"]["cachedBytes"] for g in groups) 263 + total_uniques = sum(g["uniq"]["uniques"] for g in groups) 264 + total_threats = sum(g["sum"]["threats"] for g in groups) 265 + 266 + cache_ratio = (total_cached / total_bytes * 100) if total_bytes > 0 else 0 267 + 268 + stats_table = Table(show_header=False, box=None, padding=(0, 2)) 269 + stats_table.add_column(style="dim") 270 + stats_table.add_column(style="bold green", justify="right") 271 + 272 + stats_table.add_row("total requests", f"{total_requests:,}") 273 + stats_table.add_row("page views", f"{total_pageviews:,}") 274 + stats_table.add_row("unique visitors", f"{total_uniques:,}") 275 + stats_table.add_row("bandwidth", format_bytes(total_bytes)) 276 + stats_table.add_row("cache hit ratio", f"{cache_ratio:.1f}%") 277 + stats_table.add_row("threats blocked", f"{total_threats:,}") 278 + 279 + console.print( 280 + Panel(stats_table, title="[bold]zone stats[/]", border_style="blue") 281 + ) 282 + 283 + # extract data for charts 284 + dates = [g["dimensions"]["date"][-5:] for g in groups] # MM-DD 285 + requests = [g["sum"]["requests"] for g in groups] 286 + pageviews = [g["sum"]["pageViews"] for g in groups] 287 + uniques = [g["uniq"]["uniques"] for g in groups] 288 + 289 + # requests bar chart 290 + plt.clear_figure() 291 + plt.theme("dark") 292 + plt.title("daily requests") 293 + plt.bar(dates, requests, color="cyan") 294 + plt.plotsize(80, 15) 295 + plt.show() 296 + print() 297 + 298 + # pageviews vs uniques line chart 299 + plt.clear_figure() 300 + plt.theme("dark") 301 + plt.title("pageviews vs unique visitors") 302 + x = list(range(len(dates))) 303 + plt.plot(x, pageviews, label="pageviews", color="green", marker="braille") 304 + plt.plot(x, uniques, label="uniques", color="magenta", marker="braille") 305 + plt.xticks(x, dates) 306 + plt.plotsize(80, 15) 307 + plt.show() 308 + print() 309 + 310 + except Exception as e: 311 + console.print(f"[red]zone analytics error:[/] {e}") 312 + 313 + # web analytics (RUM) 314 + if settings.cf_account_id: 315 + try: 316 + data = get_web_analytics(days, use_cache=use_cache) 317 + groups = data["viewer"]["accounts"][0]["rumPageloadEventsAdaptiveGroups"] 318 + 319 + total_visits = sum(g["sum"]["visits"] for g in groups) 320 + total_pageloads = sum(g["count"] for g in groups) 321 + 322 + rum_table = Table(show_header=False, box=None, padding=(0, 2)) 323 + rum_table.add_column(style="dim") 324 + rum_table.add_column(style="bold magenta", justify="right") 325 + 326 + rum_table.add_row("visits (RUM)", f"{total_visits:,}") 327 + rum_table.add_row("page loads", f"{total_pageloads:,}") 328 + 329 + console.print( 330 + Panel(rum_table, title="[bold]web analytics[/]", border_style="magenta") 331 + ) 332 + 333 + except Exception as e: 334 + console.print(f"[yellow]web analytics:[/] {e}") 335 + 336 + console.print() 337 + 338 + 339 + @app.command() 340 + def main( 341 + days: int = typer.Option(7, "-d", "--days", help="number of days to look back"), 342 + no_cache: bool = typer.Option(False, "--no-cache", help="force refresh from API"), 343 + ) -> None: 344 + """cloudflare analytics dashboard for plyr.fm""" 345 + if not settings.cf_zone_id and not settings.cf_account_id: 346 + console.print("[red]error:[/] set CF_ZONE_ID and/or CF_ACCOUNT_ID in .env") 347 + raise typer.Exit(1) 348 + 349 + display_dashboard(days, use_cache=not no_cache) 350 + 351 + 352 + if __name__ == "__main__": 353 + app()