Created
December 31, 2025 13:18
-
-
Save eibrahim/700319aae934961239d659d22fdcdc03 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Claude Code Usage Analytics | |
| =========================== | |
| A comprehensive cost and usage analyzer for Claude Code sessions. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| from collections import defaultdict | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CONFIGURATION | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| PRICING = { | |
| 'claude-opus-4-5-20251101': { | |
| 'input': 15.0, 'output': 75.0, 'cache_read': 1.5, 'cache_write': 18.75, | |
| 'tier': 'opus', 'emoji': '🔮' | |
| }, | |
| 'claude-sonnet-4-5-20250929': { | |
| 'input': 3.0, 'output': 15.0, 'cache_read': 0.3, 'cache_write': 3.75, | |
| 'tier': 'sonnet', 'emoji': '🎵' | |
| }, | |
| 'claude-sonnet-4-20250514': { | |
| 'input': 3.0, 'output': 15.0, 'cache_read': 0.3, 'cache_write': 3.75, | |
| 'tier': 'sonnet', 'emoji': '🎵' | |
| }, | |
| 'claude-haiku-4-5-20251001': { | |
| 'input': 0.80, 'output': 4.0, 'cache_read': 0.08, 'cache_write': 1.0, | |
| 'tier': 'haiku', 'emoji': '🌸' | |
| }, | |
| 'claude-3-5-haiku-20241022': { | |
| 'input': 0.80, 'output': 4.0, 'cache_read': 0.08, 'cache_write': 1.0, | |
| 'tier': 'haiku', 'emoji': '🌸' | |
| }, | |
| } | |
| # Terminal colors | |
| class C: | |
| RESET = '\033[0m' | |
| BOLD = '\033[1m' | |
| DIM = '\033[2m' | |
| RED = '\033[91m' | |
| GREEN = '\033[92m' | |
| YELLOW = '\033[93m' | |
| BLUE = '\033[94m' | |
| MAGENTA = '\033[95m' | |
| CYAN = '\033[96m' | |
| WHITE = '\033[97m' | |
| BG_BLUE = '\033[44m' | |
| BG_GREEN = '\033[42m' | |
| BG_YELLOW = '\033[43m' | |
| BG_RED = '\033[41m' | |
| # Box drawing characters | |
| class Box: | |
| H = '─' | |
| V = '│' | |
| TL = '╭' | |
| TR = '╮' | |
| BL = '╰' | |
| BR = '╯' | |
| ML = '├' | |
| MR = '┤' | |
| MT = '┬' | |
| MB = '┴' | |
| X = '┼' | |
| DH = '═' | |
| DV = '║' | |
| DTL = '╔' | |
| DTR = '╗' | |
| DBL = '╚' | |
| DBR = '╝' | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # HELPERS | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def format_tokens(n: int) -> str: | |
| """Format token count with K/M suffix.""" | |
| if n >= 1_000_000: | |
| return f"{n/1_000_000:.1f}M" | |
| elif n >= 1_000: | |
| return f"{n/1_000:.1f}K" | |
| return str(n) | |
| def format_cost(cost: float) -> str: | |
| """Format cost with color based on amount.""" | |
| if cost >= 10: | |
| color = C.RED | |
| elif cost >= 5: | |
| color = C.YELLOW | |
| elif cost >= 1: | |
| color = C.WHITE | |
| else: | |
| color = C.GREEN | |
| return f"{color}${cost:.2f}{C.RESET}" | |
| def progress_bar(value: float, max_value: float, width: int = 20, | |
| filled_char: str = '█', empty_char: str = '░') -> str: | |
| """Create a progress bar.""" | |
| if max_value == 0: | |
| return empty_char * width | |
| ratio = min(value / max_value, 1.0) | |
| filled = int(width * ratio) | |
| return f"{C.CYAN}{filled_char * filled}{C.DIM}{empty_char * (width - filled)}{C.RESET}" | |
| def short_model_name(model: str) -> str: | |
| """Get a short display name for a model.""" | |
| return (model | |
| .replace('claude-', '') | |
| .replace('-20251101', '') | |
| .replace('-20250929', '') | |
| .replace('-20250514', '') | |
| .replace('-20241022', '')) | |
| def print_header(title: str, width: int = 60): | |
| """Print a fancy header.""" | |
| print() | |
| print(f"{C.CYAN}{Box.DTL}{Box.DH * (width-2)}{Box.DTR}{C.RESET}") | |
| padding = (width - 2 - len(title)) // 2 | |
| print(f"{C.CYAN}{Box.DV}{C.RESET}{' ' * padding}{C.BOLD}{C.WHITE}{title}{C.RESET}{' ' * (width - 2 - padding - len(title))}{C.CYAN}{Box.DV}{C.RESET}") | |
| print(f"{C.CYAN}{Box.DBL}{Box.DH * (width-2)}{Box.DBR}{C.RESET}") | |
| def print_section(title: str, width: int = 60): | |
| """Print a section divider.""" | |
| print() | |
| print(f"{C.DIM}{Box.H * 3}{C.RESET} {C.BOLD}{title}{C.RESET} {C.DIM}{Box.H * (width - 6 - len(title))}{C.RESET}") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # DATA COLLECTION | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def collect_usage(projects_dir: Path, target_date: str) -> dict: | |
| """Collect usage data for a specific date.""" | |
| data = { | |
| 'models': defaultdict(lambda: { | |
| 'input_tokens': 0, | |
| 'output_tokens': 0, | |
| 'cache_read_input_tokens': 0, | |
| 'cache_creation_input_tokens': 0, | |
| 'api_calls': 0, | |
| }), | |
| 'sessions': set(), | |
| 'projects': set(), | |
| 'hourly': defaultdict(lambda: {'tokens': 0, 'cost': 0}), | |
| 'errors': 0, | |
| } | |
| if not projects_dir.exists(): | |
| return data | |
| for project in projects_dir.iterdir(): | |
| if not project.is_dir(): | |
| continue | |
| for jsonl_file in project.glob('*.jsonl'): | |
| try: | |
| mtime = datetime.fromtimestamp(jsonl_file.stat().st_mtime) | |
| if mtime.strftime('%Y-%m-%d') != target_date: | |
| continue | |
| data['sessions'].add(jsonl_file.stem) | |
| data['projects'].add(project.name[:30]) | |
| with open(jsonl_file, 'r') as f: | |
| for line in f: | |
| try: | |
| record = json.loads(line) | |
| if 'message' not in record: | |
| continue | |
| msg = record['message'] | |
| if 'usage' not in msg: | |
| continue | |
| model = msg.get('model', 'unknown') | |
| usage = msg['usage'] | |
| data['models'][model]['input_tokens'] += usage.get('input_tokens', 0) | |
| data['models'][model]['output_tokens'] += usage.get('output_tokens', 0) | |
| data['models'][model]['cache_read_input_tokens'] += usage.get('cache_read_input_tokens', 0) | |
| data['models'][model]['cache_creation_input_tokens'] += usage.get('cache_creation_input_tokens', 0) | |
| data['models'][model]['api_calls'] += 1 | |
| # Track hourly usage | |
| if 'timestamp' in record: | |
| try: | |
| ts = datetime.fromisoformat(record['timestamp'].replace('Z', '+00:00')) | |
| hour = ts.strftime('%H:00') | |
| total_tokens = usage.get('input_tokens', 0) + usage.get('output_tokens', 0) | |
| data['hourly'][hour]['tokens'] += total_tokens | |
| except: | |
| pass | |
| except json.JSONDecodeError: | |
| data['errors'] += 1 | |
| except Exception: | |
| data['errors'] += 1 | |
| return data | |
| def calculate_costs(model_data: dict) -> dict: | |
| """Calculate costs for model usage data.""" | |
| results = {} | |
| for model, usage in model_data.items(): | |
| if model in ['<synthetic>', 'unknown']: | |
| continue | |
| if all(v == 0 for k, v in usage.items() if k != 'api_calls'): | |
| continue | |
| p = PRICING.get(model, {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}) | |
| input_cost = (usage['input_tokens'] / 1_000_000) * p.get('input', 0) | |
| output_cost = (usage['output_tokens'] / 1_000_000) * p.get('output', 0) | |
| cache_read_cost = (usage['cache_read_input_tokens'] / 1_000_000) * p.get('cache_read', 0) | |
| cache_write_cost = (usage['cache_creation_input_tokens'] / 1_000_000) * p.get('cache_write', 0) | |
| total_cost = input_cost + output_cost + cache_read_cost + cache_write_cost | |
| # Calculate cache efficiency | |
| total_input = usage['input_tokens'] + usage['cache_read_input_tokens'] | |
| cache_hit_rate = (usage['cache_read_input_tokens'] / total_input * 100) if total_input > 0 else 0 | |
| # Calculate savings from cache | |
| cache_savings = (usage['cache_read_input_tokens'] / 1_000_000) * (p.get('input', 0) - p.get('cache_read', 0)) | |
| results[model] = { | |
| **usage, | |
| 'input_cost': input_cost, | |
| 'output_cost': output_cost, | |
| 'cache_read_cost': cache_read_cost, | |
| 'cache_write_cost': cache_write_cost, | |
| 'total_cost': total_cost, | |
| 'cache_hit_rate': cache_hit_rate, | |
| 'cache_savings': cache_savings, | |
| 'emoji': p.get('emoji', '🤖'), | |
| 'tier': p.get('tier', 'unknown'), | |
| } | |
| return results | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # DISPLAY | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def display_summary(data: dict, costs: dict, target_date: str): | |
| """Display the usage summary.""" | |
| total_cost = sum(c['total_cost'] for c in costs.values()) | |
| total_tokens = sum( | |
| c['input_tokens'] + c['output_tokens'] + c['cache_read_input_tokens'] | |
| for c in costs.values() | |
| ) | |
| total_api_calls = sum(c['api_calls'] for c in costs.values()) | |
| total_savings = sum(c['cache_savings'] for c in costs.values()) | |
| # Header | |
| date_display = "Today" if target_date == datetime.now().strftime('%Y-%m-%d') else target_date | |
| print_header(f"Claude Code Analytics - {date_display}") | |
| # Quick stats | |
| print_section("Overview") | |
| print(f" {C.CYAN}Sessions:{C.RESET} {len(data['sessions']):>6}") | |
| print(f" {C.CYAN}Projects:{C.RESET} {len(data['projects']):>6}") | |
| print(f" {C.CYAN}API Calls:{C.RESET} {total_api_calls:>6,}") | |
| print(f" {C.CYAN}Tokens:{C.RESET} {format_tokens(total_tokens):>6}") | |
| print(f" {C.CYAN}Total Cost:{C.RESET} {format_cost(total_cost)}") | |
| if total_savings > 0: | |
| print(f" {C.GREEN}Cache Saved:{C.RESET} {C.GREEN}${total_savings:.2f}{C.RESET}") | |
| if not costs: | |
| print(f"\n {C.DIM}No usage data found for {target_date}{C.RESET}") | |
| return | |
| # Model breakdown | |
| print_section("Model Breakdown") | |
| max_cost = max(c['total_cost'] for c in costs.values()) if costs else 1 | |
| for model in sorted(costs.keys(), key=lambda m: costs[m]['total_cost'], reverse=True): | |
| c = costs[model] | |
| name = short_model_name(model) | |
| emoji = c['emoji'] | |
| print(f"\n {emoji} {C.BOLD}{name}{C.RESET}") | |
| print(f" {progress_bar(c['total_cost'], max_cost, 30)} {format_cost(c['total_cost'])}") | |
| # Token details | |
| print(f" {C.DIM}Input:{C.RESET} {format_tokens(c['input_tokens']):>8} " | |
| f"{C.DIM}Output:{C.RESET} {format_tokens(c['output_tokens']):>8} " | |
| f"{C.DIM}Calls:{C.RESET} {c['api_calls']:,}") | |
| # Cache stats | |
| if c['cache_read_input_tokens'] > 0 or c['cache_creation_input_tokens'] > 0: | |
| cache_bar = progress_bar(c['cache_hit_rate'], 100, 10) | |
| print(f" {C.DIM}Cache:{C.RESET} R:{format_tokens(c['cache_read_input_tokens']):>6} " | |
| f"W:{format_tokens(c['cache_creation_input_tokens']):>6} " | |
| f"{cache_bar} {c['cache_hit_rate']:.0f}% hit") | |
| # Cost breakdown pie | |
| print_section("Cost Distribution") | |
| tier_costs = defaultdict(float) | |
| for model, c in costs.items(): | |
| tier_costs[c['tier']] += c['total_cost'] | |
| for tier in ['opus', 'sonnet', 'haiku']: | |
| if tier in tier_costs: | |
| pct = (tier_costs[tier] / total_cost * 100) if total_cost > 0 else 0 | |
| bar_width = int(pct / 100 * 40) | |
| tier_color = {'opus': C.MAGENTA, 'sonnet': C.BLUE, 'haiku': C.GREEN}.get(tier, C.WHITE) | |
| print(f" {tier_color}{tier.capitalize():>8}{C.RESET} {tier_color}{'█' * bar_width}{C.DIM}{'░' * (40-bar_width)}{C.RESET} {pct:5.1f}% (${tier_costs[tier]:.2f})") | |
| # Hourly activity (if data available) | |
| if data['hourly']: | |
| print_section("Activity Timeline") | |
| max_tokens = max(h['tokens'] for h in data['hourly'].values()) if data['hourly'] else 1 | |
| hours = sorted(data['hourly'].keys()) | |
| line1 = " " | |
| line2 = " " | |
| for hour in hours: | |
| h = data['hourly'][hour] | |
| level = int((h['tokens'] / max_tokens) * 8) if max_tokens > 0 else 0 | |
| blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] | |
| char = blocks[min(level, 7)] if level > 0 else ' ' | |
| line1 += f"{C.CYAN}{char}{C.RESET}" | |
| line2 += hour[0] # Just first digit of hour | |
| if hours: | |
| print(line1) | |
| print(f" {C.DIM}{line2}{C.RESET}") | |
| # Projects used | |
| if data['projects']: | |
| print_section("Active Projects") | |
| for i, proj in enumerate(sorted(data['projects'])[:8]): | |
| print(f" {C.DIM}•{C.RESET} {proj}") | |
| if len(data['projects']) > 8: | |
| print(f" {C.DIM} ... and {len(data['projects']) - 8} more{C.RESET}") | |
| # Footer | |
| print() | |
| print(f"{C.DIM}{'─' * 60}{C.RESET}") | |
| print(f" {C.BOLD}Total: {format_cost(total_cost)}{C.RESET}", end='') | |
| if total_savings > 0: | |
| print(f" {C.DIM}│{C.RESET} {C.GREEN}Saved: ${total_savings:.2f} via cache{C.RESET}", end='') | |
| print() | |
| def display_comparison(projects_dir: Path, days: int = 7): | |
| """Display comparison across multiple days.""" | |
| print_header(f"Last {days} Days Comparison") | |
| daily_costs = [] | |
| for i in range(days): | |
| date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') | |
| data = collect_usage(projects_dir, date) | |
| costs = calculate_costs(data['models']) | |
| total = sum(c['total_cost'] for c in costs.values()) | |
| daily_costs.append((date, total, len(data['sessions']))) | |
| max_cost = max(d[1] for d in daily_costs) if daily_costs else 1 | |
| print() | |
| for date, cost, sessions in reversed(daily_costs): | |
| day_name = datetime.strptime(date, '%Y-%m-%d').strftime('%a') | |
| is_today = date == datetime.now().strftime('%Y-%m-%d') | |
| marker = f"{C.YELLOW}→{C.RESET}" if is_today else " " | |
| bar = progress_bar(cost, max_cost, 25) | |
| print(f" {marker} {C.DIM}{day_name}{C.RESET} {date[-5:]} {bar} {format_cost(cost):>12} {C.DIM}({sessions} sessions){C.RESET}") | |
| total_period = sum(d[1] for d in daily_costs) | |
| avg_daily = total_period / days | |
| # Calculate some extra stats | |
| active_days = sum(1 for d in daily_costs if d[1] > 0) | |
| max_day = max(daily_costs, key=lambda x: x[1]) | |
| min_nonzero = min((d for d in daily_costs if d[1] > 0), key=lambda x: x[1], default=None) | |
| print() | |
| print(f" {C.DIM}{'─' * 56}{C.RESET}") | |
| period_label = "Period" if days != 7 else "Week" | |
| print(f" {C.BOLD}{period_label} Total:{C.RESET} {format_cost(total_period)} {C.DIM}│{C.RESET} {C.BOLD}Daily Avg:{C.RESET} {format_cost(avg_daily)}") | |
| print(f" {C.DIM}Active Days:{C.RESET} {active_days}/{days} {C.DIM}│{C.RESET} {C.DIM}Peak:{C.RESET} {max_day[0][-5:]} ({format_cost(max_day[1])})") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # MAIN | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description='Claude Code Usage Analytics', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s Show today's usage | |
| %(prog)s -d 2025-01-15 Show usage for specific date | |
| %(prog)s -w Show last 7 days | |
| %(prog)s -m Show last 30 days | |
| %(prog)s -n 14 Show last 14 days | |
| %(prog)s --no-color Disable colored output | |
| %(prog)s -j Output as JSON | |
| """ | |
| ) | |
| parser.add_argument('-d', '--date', help='Date to analyze (YYYY-MM-DD)') | |
| parser.add_argument('-n', '--days', type=int, metavar='N', help='Show last N days comparison') | |
| parser.add_argument('-w', '--week', action='store_true', help='Show last 7 days (shortcut for -n 7)') | |
| parser.add_argument('-m', '--month', action='store_true', help='Show last 30 days (shortcut for -n 30)') | |
| parser.add_argument('--no-color', action='store_true', help='Disable colors') | |
| parser.add_argument('-j', '--json', action='store_true', help='Output as JSON') | |
| args = parser.parse_args() | |
| # Disable colors if requested or if not a TTY | |
| if args.no_color or not sys.stdout.isatty(): | |
| for attr in dir(C): | |
| if not attr.startswith('_'): | |
| setattr(C, attr, '') | |
| projects_dir = Path.home() / '.claude' / 'projects' | |
| target_date = args.date or datetime.now().strftime('%Y-%m-%d') | |
| # Collect data | |
| data = collect_usage(projects_dir, target_date) | |
| costs = calculate_costs(data['models']) | |
| if args.json: | |
| output = { | |
| 'date': target_date, | |
| 'sessions': len(data['sessions']), | |
| 'projects': list(data['projects']), | |
| 'models': {short_model_name(m): { | |
| 'tokens': { | |
| 'input': c['input_tokens'], | |
| 'output': c['output_tokens'], | |
| 'cache_read': c['cache_read_input_tokens'], | |
| 'cache_write': c['cache_creation_input_tokens'], | |
| }, | |
| 'cost': round(c['total_cost'], 4), | |
| 'api_calls': c['api_calls'], | |
| 'cache_hit_rate': round(c['cache_hit_rate'], 2), | |
| } for m, c in costs.items()}, | |
| 'total_cost': round(sum(c['total_cost'] for c in costs.values()), 4), | |
| } | |
| print(json.dumps(output, indent=2)) | |
| return | |
| # Display | |
| display_summary(data, costs, target_date) | |
| # Determine days for comparison | |
| compare_days = None | |
| if args.days: | |
| compare_days = args.days | |
| elif args.month: | |
| compare_days = 30 | |
| elif args.week: | |
| compare_days = 7 | |
| if compare_days: | |
| display_comparison(projects_dir, days=compare_days) | |
| print() | |
| if __name__ == '__main__': | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
run it with

python3 calc_claude.py -hto see all the options. Here is a 30 day example: