#!/usr/bin/env python3 """Claude Code + GitHub Copilot usage stats web app.""" import csv import json import os # Load .env if present (does not override existing env vars) _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") if os.path.exists(_env_path): with open(_env_path) as _f: for _line in _f: _line = _line.strip() if _line and not _line.startswith("#") and "=" in _line: _k, _, _v = _line.partition("=") os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'")) import re import ssl import unicodedata from collections import defaultdict from datetime import date, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.error import HTTPError from urllib.request import Request, urlopen _HERE = os.path.dirname(os.path.abspath(__file__)) MAPPING_FILE = os.path.join(_HERE, "user_mapping.csv") ANTHROPIC_API = "https://api.anthropic.com/v1/organizations/usage_report/claude_code" PORT = 8765 TODAY = date.today() WINDOW_DAYS = 28 ORG = "theorchard" _ssl_ctx = ssl.create_default_context() if os.path.exists("/etc/ssl/cert.pem"): _ssl_ctx.load_verify_locations(cafile="/etc/ssl/cert.pem") # ── helpers ─────────────────────────────────────────────────────────────────── def days_since(date_str): if not date_str: return None try: return (TODAY - date.fromisoformat(date_str[:10])).days except ValueError: return None def ascii_lower(s): for k, v in {"Ø":"o","ø":"o","Å":"a","å":"a","Æ":"ae","æ":"ae","Ð":"d","ð":"d","Þ":"th","þ":"th","Ł":"l","ł":"l"}.items(): s = s.replace(k, v) return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower().strip() def name_candidates(name): parts = ascii_lower(name).split() if not parts: return set() if len(parts[0]) <= 2 and parts[0].endswith("."): parts = parts[1:] if not parts: return set() first, *rest = parts last = rest[-1] if rest else "" compound = "".join(rest) if rest else "" cands = set() if last: cands.add(first[0] + last) cands.add(first + last) cands.add(first + "." + last) cands.add(first[0] + "." + last) if compound and compound != last: cands.add(first[0] + compound) if len(rest) >= 2: cands.add(first[0] + "".join(rest)) cands.add(first) return cands def clean_token(s): return re.sub(r"[-_.\d]", "", s.lower()) def load_manual_mapping(): if not os.path.exists(MAPPING_FILE): return {} with open(MAPPING_FILE, newline="") as f: return {r["gh_login"].lower(): r["claude_email"] for r in csv.DictReader(f)} def match_users(copilot_rows, claude_emails): prefix_map = {e.split("@")[0].lower(): e for e in claude_emails if "@" in e} clean_map = {clean_token(p): e for p, e in prefix_map.items()} manual = load_manual_mapping() result = {} for r in copilot_rows: login = r["login"] gh_email = (r.get("email") or "").lower().strip() gh_name = r.get("name") or "" matched = manual.get(login.lower()) if not matched and gh_email and gh_email in {e.lower() for e in claude_emails}: matched = gh_email if not matched and "@" in gh_email: matched = prefix_map.get(gh_email.split("@")[0].lower()) if not matched and gh_name: for c in name_candidates(gh_name): if c in prefix_map: matched = prefix_map[c]; break if not matched and gh_name: for c in name_candidates(gh_name): for pfx, email in prefix_map.items(): if pfx.startswith(c) and len(pfx) - len(c) <= 2: matched = email; break if matched: break if not matched: matched = clean_map.get(clean_token(login)) if not matched: cl = clean_token(login) for pfx, email in prefix_map.items(): cp = clean_token(pfx) if len(cp) >= 6 and cp in cl: matched = email; break result[login] = matched return result # ── data loading ────────────────────────────────────────────────────────────── def _fetch_claude_day(api_key, day_str): rows, cursor = [], None while True: params = f"starting_at={day_str}&limit=1000" if cursor: params += f"&page={cursor}" req = Request(f"{ANTHROPIC_API}?{params}", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", }) with urlopen(req, context=_ssl_ctx) as resp: data = json.loads(resp.read()) for record in data.get("data", []): rows.extend(_flatten_claude(record)) if data.get("has_more") and data.get("next_page"): cursor = data["next_page"] else: break return rows def fetch_claude(api_key): from concurrent.futures import ThreadPoolExecutor end = date.today() - timedelta(days=1) start = end - timedelta(days=WINDOW_DAYS - 1) days = [(start + timedelta(days=i)).isoformat() for i in range(WINDOW_DAYS)] with ThreadPoolExecutor(max_workers=10) as ex: results = ex.map(lambda d: _fetch_claude_day(api_key, d), days) return [row for day_rows in results for row in day_rows] def _flatten_claude(record): actor = record.get("actor", {}) if actor.get("type") == "user_actor": actor_id, actor_type = actor.get("email_address", ""), "user" else: actor_id, actor_type = actor.get("api_key_name", ""), "api_key" core = record.get("core_metrics", {}) loc = core.get("lines_of_code", {}) tools = record.get("tool_actions", {}) base = { "date": record.get("date", "")[:10], "email_or_key": actor_id, "actor_type": actor_type, "terminal_type": record.get("terminal_type", ""), "customer_type": record.get("customer_type", ""), "num_sessions": core.get("num_sessions", 0), "lines_added": loc.get("added", 0), "lines_removed": loc.get("removed", 0), "commits": core.get("commits_by_claude_code", 0), "pull_requests": core.get("pull_requests_by_claude_code", 0), "edit_accepted": tools.get("edit_tool", {}).get("accepted", 0), "edit_rejected": tools.get("edit_tool", {}).get("rejected", 0), "multi_edit_accepted": tools.get("multi_edit_tool", {}).get("accepted", 0), "multi_edit_rejected": tools.get("multi_edit_tool", {}).get("rejected", 0), "write_accepted": tools.get("write_tool", {}).get("accepted", 0), "write_rejected": tools.get("write_tool", {}).get("rejected", 0), } model_breakdown = record.get("model_breakdown", []) if not model_breakdown: return [{**base, "model": "", "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0, "estimated_cost_usd_cents": 0}] result = [] for m in model_breakdown: tokens = m.get("tokens", {}) cost = m.get("estimated_cost", {}) result.append({**base, "model": m.get("model", ""), "input_tokens": tokens.get("input", 0), "output_tokens": tokens.get("output", 0), "cache_read_tokens": tokens.get("cache_read", 0), "cache_creation_tokens": tokens.get("cache_creation", 0), "estimated_cost_usd_cents": cost.get("amount", 0), }) return result # ── GitHub Copilot fetch ────────────────────────────────────────────────────── def gh_get(token, path): url = f"https://api.github.com{path}" req = Request(url, headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }) with urlopen(req, context=_ssl_ctx) as resp: return json.loads(resp.read()), resp.headers.get("Link", "") def fetch_copilot_seats(token): seats, path = [], f"/orgs/{ORG}/copilot/billing/seats?per_page=100" while path: data, link = gh_get(token, path) seats.extend(data.get("seats", [])) path = None for part in link.split(","): if 'rel="next"' in part: path = part.split(";")[0].strip().strip("<>").replace("https://api.github.com", "") return seats def fetch_copilot_usage(token): try: data, _ = gh_get(token, f"/orgs/{ORG}/copilot/usage?per_page=28") return data except HTTPError: return [] def fetch_user_details(token, logins): """Fetch name + email for multiple logins in a single GraphQL request.""" aliases = "\n".join( f'u{i}: user(login: {json.dumps(login)}) {{ name email }}' for i, login in enumerate(logins) ) query = json.dumps({"query": f"{{ {aliases} }}"}).encode() req = Request("https://api.github.com/graphql", data=query, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }) with urlopen(req, context=_ssl_ctx) as resp: result = json.loads(resp.read()) data = result.get("data") or {} return { login: (data.get(f"u{i}") or {}) for i, login in enumerate(logins) } def fetch_copilot_live(token): """Fetch seats + usage from GitHub without writing to disk.""" seats = fetch_copilot_seats(token) logins = [(s.get("assignee") or {}).get("login", "") for s in seats] user_details = fetch_user_details(token, logins) rows = [] for s in seats: assignee = s.get("assignee") or {} login = assignee.get("login", "") team = s.get("assigning_team") or {} details = user_details.get(login) or {} rows.append({ "login": login, "name": details.get("name") or "", "email": details.get("email") or "", "seat_created_at": (s.get("created_at") or "")[:10], "seat_updated_at": (s.get("updated_at") or "")[:10], "last_activity_at": (s.get("last_activity_at") or "")[:10], "last_activity_editor": s.get("last_activity_editor") or "", "pending_cancellation_date": s.get("pending_cancellation_date") or "", "plan_type": s.get("plan_type") or "", "assigning_team": team.get("name") or team.get("slug") or "", }) usage = fetch_copilot_usage(token) return rows, usage # ── aggregation ─────────────────────────────────────────────────────────────── def aggregate_claude(rows, since_days=None): cutoff = (TODAY - timedelta(days=since_days)).isoformat() if since_days else None users = defaultdict(lambda: defaultdict(int)) for r in rows: if cutoff and r["date"] < cutoff: continue u = users[r["email_or_key"]] u["email_or_key"] = r["email_or_key"] u["cost_cents"] += r["estimated_cost_usd_cents"] u["sessions"] += r["num_sessions"] u["lines_added"] += r["lines_added"] u["lines_removed"]+= r["lines_removed"] u["commits"] += r["commits"] u["pull_requests"]+= r["pull_requests"] u["edit_accepted"]+= r["edit_accepted"] u["edit_rejected"]+= r["edit_rejected"] u["input_tokens"] += r["input_tokens"] u["output_tokens"]+= r["output_tokens"] user_list = [dict(v) for v in users.values()] user_list.sort(key=lambda x: x["cost_cents"], reverse=True) for u in user_list: tot = u["edit_accepted"] + u["edit_rejected"] u["acceptance_rate"] = round(u["edit_accepted"] / tot * 100, 1) if tot else 0 u["cost_dollars"] = round(u["cost_cents"] / 100, 2) lbl = u["email_or_key"] u["label"] = lbl.split("@")[0] if "@" in lbl else lbl[:24] return user_list def _cp_ds(row): ds = days_since(row.get("last_activity_at")) return ds if ds is not None else 999 def build_data(claude_rows, copilot_rows, usage_rows=None): """Return all dashboard data as a plain Python dict (JSON-serialisable).""" claude_28d = aggregate_claude(claude_rows, since_days=WINDOW_DAYS) active_28d = [u for u in claude_28d if u["cost_cents"] > 0] all_emails = {r["email_or_key"] for r in claude_rows} l2e = match_users(copilot_rows, all_emails) cutoff = (TODAY - timedelta(days=WINDOW_DAYS)).isoformat() active_cc_emails = {u["email_or_key"] for u in active_28d} cp_active_28d_set = {r["login"] for r in copilot_rows if (_cp_ds(r)) <= WINDOW_DAYS} claude_by_email = {u["email_or_key"]: u for u in claude_28d} # Build matrix both, cp_only, neither, unmatched_list = [], [], [], [] for r in copilot_rows: login = r["login"] claude_email = l2e.get(login) cp_active = login in cp_active_28d_set cc_active = bool(claude_email and claude_email in active_cc_emails) cu = claude_by_email.get(claude_email or "") entry = { "login": login, "label": (claude_email.split("@")[0] if claude_email and "@" in claude_email else login), "claude_email": claude_email or "", "copilot_last_active": (r.get("last_activity_at") or "")[:10], "copilot_days_since": days_since(r.get("last_activity_at")), "copilot_editor": (r.get("last_activity_editor") or "").split("/")[0], "cc_cost": cu["cost_dollars"] if cu else 0, "cc_sessions": cu["sessions"] if cu else 0, "cc_lines_added": cu["lines_added"] if cu else 0, "cc_commits": cu["commits"] if cu else 0, "cc_acceptance_rate": cu["acceptance_rate"] if cu else 0, } if not claude_email: entry["quadrant"] = "unmatched"; unmatched_list.append(entry) elif cc_active: entry["quadrant"] = "both"; both.append(entry) elif cp_active: entry["quadrant"] = "copilot_only"; cp_only.append(entry) else: entry["quadrant"] = "neither"; neither.append(entry) matched_emails = set(l2e.values()) - {None} cc_no_seat = [{ "quadrant": "claude_only", "label": u["label"], "claude_email": u["email_or_key"], "login": "", "copilot_last_active": "", "copilot_days_since": None, "copilot_editor": "", "cc_cost": u["cost_dollars"], "cc_sessions": u["sessions"], "cc_lines_added": u["lines_added"], "cc_commits": u["commits"], "cc_acceptance_rate": u["acceptance_rate"], } for u in active_28d if u["email_or_key"] not in matched_emails] # Copilot usage: lines accepted by language lines_by_lang = defaultdict(int) for day in (usage_rows or []): for b in day.get("breakdown", []): lang = (b.get("language") or "unknown").lower() lines_by_lang[lang] += b.get("lines_accepted", 0) top_langs = sorted(lines_by_lang.items(), key=lambda x: x[1], reverse=True)[:12] # Breakdowns models, terminals, cp_editors = defaultdict(int), defaultdict(int), defaultdict(int) for r in claude_rows: if r["date"] < cutoff: continue if r["model"]: models[r["model"]] += r["estimated_cost_usd_cents"] if r["terminal_type"]: terminals[r["terminal_type"]] += r["num_sessions"] for r in copilot_rows: if (_cp_ds(r)) > WINDOW_DAYS: continue ed = (r.get("last_activity_editor") or "").lower() if "vscode" in ed: key = "VS Code" elif any(x in ed for x in ("jetbrains","idea","pycharm","webstorm","rider")): key = "JetBrains" elif "neovim" in ed or "nvim" in ed: key = "Neovim" elif "vim" in ed: key = "Vim" elif "xcode" in ed: key = "Xcode" elif "cli" in ed: key = "CLI" elif ed: key = ed.split("/")[0][:15] else: key = "Unknown" cp_editors[key] += 1 return { "window_days": WINDOW_DAYS, "generated_at": TODAY.isoformat(), "claude": { "cost": round(sum(u["cost_cents"] for u in active_28d) / 100, 2), "active_users": len(active_28d), "sessions": sum(u["sessions"] for u in active_28d), "lines_added": sum(u["lines_added"] for u in active_28d), "commits": sum(u["commits"] for u in active_28d), }, "copilot": { "total_seats": len(copilot_rows), "active_28d": len(cp_active_28d_set), "both": len(both), "cp_only": len(cp_only), "cc_no_seat": len(cc_no_seat), "neither": len(neither), }, "charts": { "venn": {"labels": ["Both tools","Claude only (no seat)","Copilot only","Neither active"], "values": [len(both), len(cc_no_seat), len(cp_only), len(neither)]}, "cp_editors":{"labels": list(cp_editors.keys()), "values": list(cp_editors.values())}, "top10_cost":{"labels": [u["label"] for u in active_28d[:10]], "values": [u["cost_dollars"] for u in active_28d[:10]]}, "top10_sess":{"labels": [u["label"] for u in sorted(active_28d, key=lambda x: x["sessions"], reverse=True)[:10]], "values": [u["sessions"] for u in sorted(active_28d, key=lambda x: x["sessions"], reverse=True)[:10]]}, "cp_top": {"labels": [r.get("name") or r["login"] for r in sorted([r for r in copilot_rows if (_cp_ds(r)) <= WINDOW_DAYS], key=lambda x: x.get("last_activity_at",""), reverse=True)[:10]], "values": [days_since(r.get("last_activity_at")) or 0 for r in sorted([r for r in copilot_rows if (_cp_ds(r)) <= WINDOW_DAYS], key=lambda x: x.get("last_activity_at",""), reverse=True)[:10]]}, "models": {"labels": list(models.keys()), "values": [round(v/100,2) for v in models.values()]}, "terminals": {"labels": list(terminals.keys()), "values": list(terminals.values())}, "cp_lines_by_lang": {"labels": [x[0] for x in top_langs], "values": [x[1] for x in top_langs]}, }, "matrix": both + cc_no_seat + cp_only + neither + unmatched_list, "match_stats": { "matched": sum(1 for v in l2e.values() if v), "total": len(copilot_rows), "unmatched_logins": [k for k, v in l2e.items() if not v], }, } # ── HTML shell (static) ─────────────────────────────────────────────────────── HTML = """