Skip to content

Instantly share code, notes, and snippets.

@eibrahim
Created December 31, 2025 13:18
Show Gist options
  • Select an option

  • Save eibrahim/700319aae934961239d659d22fdcdc03 to your computer and use it in GitHub Desktop.

Select an option

Save eibrahim/700319aae934961239d659d22fdcdc03 to your computer and use it in GitHub Desktop.
#!/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()
@eibrahim
Copy link
Author

run it with python3 calc_claude.py -h to see all the options. Here is a 30 day example:
Snagit 2025-12-31 08 22 10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment