at main 11 kB view raw
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 8usage: 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 15import hashlib 16import json 17from datetime import datetime, timedelta 18from pathlib import Path 19from typing import Any 20 21import httpx 22import plotext as plt 23import typer 24from pydantic_settings import BaseSettings, SettingsConfigDict 25from rich.console import Console 26from rich.panel import Panel 27from rich.table import Table 28 29 30class 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 38settings = Settings() 39 40GRAPHQL_ENDPOINT = "https://api.cloudflare.com/client/v4/graphql" 41CACHE_DIR = Path.home() / ".cache" / "plyr-analytics" 42 43console = Console() 44app = typer.Typer(add_completion=False) 45 46 47def 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 54def 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 68def 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 76def 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 90def 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 110def 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 150def 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 183def 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 211def 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 239def 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 248def 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() 340def 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 352if __name__ == "__main__": 353 app()