Skip to content

Instantly share code, notes, and snippets.

@esc5221
Last active June 5, 2025 04:47
Show Gist options
  • Save esc5221/df0a0c3c068b8dd92282837389addb35 to your computer and use it in GitHub Desktop.
Save esc5221/df0a0c3c068b8dd92282837389addb35 to your computer and use it in GitHub Desktop.
claude_code_cost_visualizer.py
#!/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