Last active
June 5, 2025 04:47
-
-
Save esc5221/df0a0c3c068b8dd92282837389addb35 to your computer and use it in GitHub Desktop.
claude_code_cost_visualizer.py
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 -S uv run --script | |
# /// script | |
# requires-python = ">=3.9" | |
# dependencies = [ | |
# "pandas>=2.0.0", | |
# "plotly>=6.0.0", | |
# "nbformat>=5.0.0", | |
# ] | |
# /// | |
""" | |
Claude Code Cost Visualizer | |
This script analyzes Claude Code usage costs from .jsonl files in the Claude projects directory. | |
It creates an interactive visualization with two charts: | |
- Daily costs by project (stacked area chart) | |
- Cumulative costs over time (line/bar chart) | |
The visualization is saved as an HTML file with interactive features and also displayed if possible. | |
Terminal output shows a simple, aligned cost summary by project. | |
Usage: | |
./claude_code_cost_visualizer.py | |
(Dependencies will be automatically installed by uv) | |
""" | |
import os | |
import json | |
import glob | |
from datetime import datetime | |
import pandas as pd | |
import plotly.graph_objects as go | |
from collections import defaultdict | |
from plotly.subplots import make_subplots # Moved import here for consistency | |
# Find all JSONL files in the Claude projects directory | |
# Use os.path.expanduser to correctly resolve the '~' to the user's home directory | |
project_dir = os.path.expanduser("~/.claude/projects/") | |
jsonl_files = glob.glob(f"{project_dir}/**/*.jsonl", recursive=True) | |
# Dictionary to store data: {date: {project: cost}} | |
data = defaultdict(lambda: defaultdict(float)) | |
project_names = set() | |
total_cost = 0 | |
# Process each JSONL file | |
for file_path in jsonl_files: | |
try: | |
# Extract project name from path | |
parts = file_path.split(os.sep) # Use os.sep for platform-independent path splitting | |
try: | |
# Find the '.claude' part and get the project name after 'projects' | |
# Adjusting index to handle both hardcoded path structure and '~' expanded paths | |
claude_idx = -1 | |
for i, part in enumerate(parts): | |
if part == '.claude': | |
claude_idx = i | |
break | |
if claude_idx != -1 and claude_idx + 2 < len(parts): # Ensure 'projects' and project name exist | |
# If the path looks like /home/user/.claude/projects/project_name/... | |
project_name = parts[claude_idx + 2] | |
# Further cleanup for names like '-Users-lullu-' | |
project_name = project_name.replace('-Users-lullu-', '').replace('-', '/') | |
else: | |
project_name = "unknown" # Fallback if structure not as expected | |
except ValueError: | |
# Fallback if '.claude' not in path | |
project_name = os.path.basename(os.path.dirname(file_path)) | |
project_names.add(project_name) | |
# Process each line in the JSONL file | |
with open(file_path, 'r') as f: | |
for line in f: | |
try: | |
entry = json.loads(line) | |
# Check if costUSD exists | |
cost = entry.get('costUSD', 0) | |
if cost: | |
# Extract date from timestamp | |
timestamp = entry.get('timestamp') | |
if timestamp: | |
date = timestamp.split('T')[0] # Get YYYY-MM-DD part | |
# Add cost to the appropriate date and project | |
data[date][project_name] += cost | |
total_cost += cost | |
except (json.JSONDecodeError, KeyError) as e: | |
continue # Skip invalid lines | |
except Exception as e: | |
print(f"Error processing file {file_path}: {e}") | |
continue | |
# Convert to DataFrame for easier plotting | |
dates = sorted(data.keys()) | |
projects = sorted(list(project_names)) # Convert set to list for sorting | |
# Create DataFrame | |
df = pd.DataFrame(index=dates, columns=projects) | |
for date in dates: | |
for project in projects: | |
df.loc[date, project] = data[date].get(project, 0) | |
# Fill NaN values with 0 | |
df = df.fillna(0) | |
# Calculate daily totals and filter out days with zero cost | |
df['daily_total'] = df.sum(axis=1) | |
df = df[df['daily_total'] > 0] # Keep only days with cost > 0 | |
df = df.drop(columns=['daily_total']) # Remove the temporary column | |
# Create a single figure with two subplots sharing the x-axis | |
# Create figure with 2 rows (subplots stacked vertically) | |
fig = make_subplots( | |
rows=2, | |
cols=1, | |
shared_xaxes=True, # Share x-axes between subplots | |
vertical_spacing=0.1, # Add some space between the subplots | |
subplot_titles=('Daily Claude Code Usage Cost by Project', 'Cumulative Claude Code Usage Cost') | |
) | |
# 1. Daily stacked area chart (top subplot) | |
for project in projects: | |
fig.add_trace( | |
go.Scatter( | |
x=df.index, | |
y=df[project], | |
mode='lines', | |
name=project, | |
stackgroup='one', | |
hoverinfo='x+y+name' | |
), | |
row=1, col=1 # First subplot (top) | |
) | |
# Add total cost line to daily chart | |
df['Total'] = df.sum(axis=1) | |
fig.add_trace( | |
go.Scatter( | |
x=df.index, | |
y=df['Total'], | |
mode='lines', | |
name='Total Cost', | |
line=dict(width=2, color='black', dash='dash'), | |
), | |
row=1, col=1 # First subplot (top) | |
) | |
# 2. Cumulative cost chart (bottom subplot) | |
df['Cumulative'] = df['Total'].cumsum() | |
fig.add_trace( | |
go.Scatter( | |
x=df.index, | |
y=df['Cumulative'], | |
mode='lines', | |
name='Cumulative Cost', | |
line=dict(width=3, color='red'), | |
fill='tozeroy', | |
), | |
row=2, col=1 # Second subplot (bottom) | |
) | |
# Optionally add daily cost bars to the cumulative chart | |
fig.add_trace( | |
go.Bar( | |
x=df.index, | |
y=df['Total'], | |
name='Daily Cost', | |
marker_color='rgba(55, 83, 109, 0.7)', | |
opacity=0.7, | |
showlegend=False, # Hide from legend since it's already in the top subplot | |
), | |
row=2, col=1 # Second subplot (bottom) | |
) | |
# Update layout for entire figure | |
fig.update_layout( | |
title=f'Claude Code Usage Cost Analysis (Total: ${total_cost:.2f})', | |
hovermode='x unified', # Unified hover mode across all subplots | |
legend_title='Projects/Costs', | |
width=1600, | |
height=900, | |
margin=dict(l=50, r=50, t=100, b=100), | |
) | |
# Update y-axis labels for each subplot | |
fig.update_yaxes(title_text="Daily Cost (USD)", row=1, col=1) | |
fig.update_yaxes(title_text="Cumulative Cost (USD)", row=2, col=1) | |
fig.update_xaxes(title_text="Date", row=2, col=1) # Only add x-axis title to bottom subplot | |
# Save HTML file | |
html_path = os.path.expanduser("~/claude_code_cost_analysis.html") | |
fig.write_html(html_path) | |
print(f"Saved Claude Code cost analysis chart to {html_path}") | |
# Print summary with simple formatting | |
print("\n" + "="*50) | |
print(f"Total Claude Code usage cost: ${total_cost:.2f}") | |
# Get project totals and sort by cost | |
project_totals = df[projects].sum().sort_values(ascending=False) | |
# Calculate the max length of project names for alignment | |
max_project_len = max([len(p) for p in projects]) if projects else 0 | |
print("\nCost by project:") | |
print("-"*50) | |
# Print sorted projects with dollar amounts aligned | |
for project, cost in project_totals.items(): | |
cost_str = f"${cost:.2f}" | |
print(f"{project.ljust(max_project_len)} : {cost_str.rjust(10)}") | |
print("="*50) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment