{"slug": "claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and", "title": "Claude Code token usage analyzer - breaks down usage by project, session, and subagent", "summary": "Based on the provided code, this is a Python tool called \"Claude Code token usage analyzer\" that parses JSONL files from `~/.claude/projects/` to analyze token usage patterns. The script extracts token usage data (input, output, cache creation, and cache read tokens) from assistant messages, identifies human-originated prompts while filtering out tool results and sidechain messages, and supports filtering by date range through environment variables. The tool also tracks session metadata including agent IDs, session IDs, and timestamps, with the ability to analyze subagent sessions.", "body_md": "#!/usr/bin/env python3\n\"\"\"\nClaude Code token usage analyzer.\nAnalyzes ~/.claude/projects/ JSONL files for token usage patterns.\n\"\"\"\n\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom collections import defaultdict\nfrom datetime import datetime, timedelta, timezone\n\nPROJECTS_DIR = Path.home() / \".claude\" / \"projects\"\nOUTPUT_DIR = Path.home() / \"tuin\" / \"analysis\" / \"tokens\"\n\n# Filter: only include sessions that started within the last N days (None = all time)\nSINCE_DAYS = int(os.environ.get(\"SINCE_DAYS\", \"0\")) or None\nSINCE_DATE = os.environ.get(\"SINCE_DATE\")  # e.g. \"2026-03-30\"\n\n\ndef extract_text_content(content):\n    \"\"\"Extract text from message content (string or list).\"\"\"\n    if isinstance(content, str):\n        return content\n    if isinstance(content, list):\n        parts = []\n        for item in content:\n            if isinstance(item, dict):\n                if item.get(\"type\") == \"text\":\n                    parts.append(item.get(\"text\", \"\"))\n                elif item.get(\"type\") == \"tool_result\":\n                    # Skip tool results - not user prompts\n                    pass\n            elif isinstance(item, str):\n                parts.append(item)\n        return \"\\n\".join(parts).strip()\n    return \"\"\n\n\ndef is_human_prompt(msg_obj):\n    \"\"\"Check if this is a human-originated prompt (not tool result).\"\"\"\n    content = msg_obj.get(\"message\", {}).get(\"content\", \"\")\n    if isinstance(content, list):\n        # If all items are tool_result, it's not a human prompt\n        types = [i.get(\"type\") for i in content if isinstance(i, dict)]\n        if types and all(t == \"tool_result\" for t in types):\n            return False\n    return True\n\n\ndef parse_session(jsonl_path, is_subagent=False):\n    \"\"\"Parse a single JSONL session file.\"\"\"\n    usage_total = defaultdict(int)\n    prompts = []\n    agent_id = None\n    session_id = None\n    timestamp_start = None\n    subagent_sessions = []\n\n    try:\n        with open(jsonl_path) as f:\n            lines = f.readlines()\n    except Exception:\n        return None\n\n    for line in lines:\n        try:\n            obj = json.loads(line)\n        except json.JSONDecodeError:\n            continue\n\n        msg_type = obj.get(\"type\")\n        ts = obj.get(\"timestamp\")\n        if ts and not timestamp_start:\n            timestamp_start = ts\n\n        if not agent_id:\n            agent_id = obj.get(\"agentId\")\n        if not session_id:\n            session_id = obj.get(\"sessionId\")\n\n        if msg_type == \"assistant\":\n            usage = obj.get(\"message\", {}).get(\"usage\", {})\n            usage_total[\"input_tokens\"] += usage.get(\"input_tokens\", 0)\n            usage_total[\"cache_creation_input_tokens\"] += usage.get(\"cache_creation_input_tokens\", 0)\n            usage_total[\"cache_read_input_tokens\"] += usage.get(\"cache_read_input_tokens\", 0)\n            usage_total[\"output_tokens\"] += usage.get(\"output_tokens\", 0)\n\n        elif msg_type == \"user\":\n            user_type = obj.get(\"userType\", \"\")\n            is_sidechain = obj.get(\"isSidechain\", False)\n            content = obj.get(\"message\", {}).get(\"content\", \"\")\n            text = extract_text_content(content)\n\n            # Only capture actual human prompts (not tool results, not sidechain)\n            if text and not is_sidechain and is_human_prompt(obj) and user_type != \"tool\":\n                prompts.append({\n                    \"text\": text,\n                    \"timestamp\": obj.get(\"timestamp\"),\n                    \"entrypoint\": obj.get(\"entrypoint\", \"\"),\n                })\n\n    # Check for subagent sessions\n    session_dir = jsonl_path.parent / jsonl_path.stem\n    if session_dir.is_dir():\n        subagents_dir = session_dir / \"subagents\"\n        if subagents_dir.is_dir():\n            for sub_file in subagents_dir.glob(\"*.jsonl\"):\n                sub_data = parse_session(sub_file, is_subagent=True)\n                if sub_data:\n                    sub_data[\"subagent_file\"] = str(sub_file.name)\n                    subagent_sessions.append(sub_data)\n\n    total_tokens = (\n        usage_total[\"input_tokens\"]\n        + usage_total[\"cache_creation_input_tokens\"]\n        + usage_total[\"cache_read_input_tokens\"]\n        + usage_total[\"output_tokens\"]\n    )\n\n    return {\n        \"file\": str(jsonl_path),\n        \"session_id\": session_id or jsonl_path.stem,\n        \"agent_id\": agent_id,\n        \"is_subagent\": is_subagent,\n        \"timestamp_start\": timestamp_start,\n        \"usage\": dict(usage_total),\n        \"total_tokens\": total_tokens,\n        \"prompts\": prompts,\n        \"subagent_sessions\": subagent_sessions,\n    }\n\n\ndef get_project_name(project_dir_name):\n    \"\"\"Convert directory name to readable project name.\"\"\"\n    # Strip leading -Users-kieranklaassen-\n    name = project_dir_name\n    prefixes = [\"-Users-kieranklaassen-\", \"Users-kieranklaassen-\"]\n    for p in prefixes:\n        if name.startswith(p):\n            name = name[len(p):]\n            break\n    return name or project_dir_name\n\n\ndef get_cutoff():\n    \"\"\"Return a UTC-aware datetime cutoff, or None for all time.\"\"\"\n    if SINCE_DATE:\n        return datetime.fromisoformat(SINCE_DATE).replace(tzinfo=timezone.utc)\n    if SINCE_DAYS:\n        return datetime.now(timezone.utc) - timedelta(days=SINCE_DAYS)\n    return None\n\n\ndef session_in_range(session, cutoff):\n    if not cutoff or not session[\"timestamp_start\"]:\n        return True\n    ts_str = session[\"timestamp_start\"]\n    # Parse ISO timestamp\n    try:\n        ts = datetime.fromisoformat(ts_str.replace(\"Z\", \"+00:00\"))\n        return ts >= cutoff\n    except ValueError:\n        return True\n\n\ndef analyze_all():\n    \"\"\"Analyze all projects and sessions.\"\"\"\n    projects = defaultdict(list)\n    cutoff = get_cutoff()\n\n    for project_dir in sorted(PROJECTS_DIR.iterdir()):\n        if not project_dir.is_dir():\n            continue\n        project_name = get_project_name(project_dir.name)\n\n        for jsonl_file in sorted(project_dir.glob(\"*.jsonl\")):\n            session = parse_session(jsonl_file)\n            if session and session[\"total_tokens\"] > 0 and session_in_range(session, cutoff):\n                projects[project_name].append(session)\n\n    return projects\n\n\ndef format_tokens(n):\n    \"\"\"Format token count with commas.\"\"\"\n    return f\"{n:,}\"\n\n\ndef summarize_projects(projects):\n    \"\"\"Build per-project summary.\"\"\"\n    summaries = []\n    for project_name, sessions in projects.items():\n        total = defaultdict(int)\n        all_subagent_tokens = 0\n        subagent_count = 0\n\n        for session in sessions:\n            for k, v in session[\"usage\"].items():\n                total[k] += v\n            for sub in session[\"subagent_sessions\"]:\n                all_subagent_tokens += sub[\"total_tokens\"]\n                subagent_count += 1\n\n        grand_total = sum(total.values())\n        summaries.append({\n            \"project\": project_name,\n            \"sessions\": len(sessions),\n            \"usage\": dict(total),\n            \"total_tokens\": grand_total,\n            \"subagent_tokens\": all_subagent_tokens,\n            \"subagent_count\": subagent_count,\n        })\n\n    summaries.sort(key=lambda x: x[\"total_tokens\"], reverse=True)\n    return summaries\n\n\ndef find_costly_sessions(projects, top_n=20):\n    \"\"\"Find the most token-heavy sessions across all projects.\"\"\"\n    all_sessions = []\n    for project_name, sessions in projects.items():\n        for session in sessions:\n            all_sessions.append((project_name, session))\n\n    all_sessions.sort(key=lambda x: x[1][\"total_tokens\"], reverse=True)\n    return all_sessions[:top_n]\n\n\ndef find_costly_subagents(projects, top_n=20):\n    \"\"\"Find the most token-heavy subagent sessions.\"\"\"\n    all_subs = []\n    for project_name, sessions in projects.items():\n        for session in sessions:\n            for sub in session[\"subagent_sessions\"]:\n                all_subs.append((project_name, session[\"session_id\"], sub))\n\n    all_subs.sort(key=lambda x: x[2][\"total_tokens\"], reverse=True)\n    return all_subs[:top_n]\n\n\ndef write_report(projects, summaries):\n    \"\"\"Write the main analysis report.\"\"\"\n    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n    report_path = OUTPUT_DIR / \"token_report.md\"\n\n    lines = []\n    cutoff = get_cutoff()\n    date_range = f\"Since {cutoff.strftime('%Y-%m-%d')}\" if cutoff else \"All time\"\n    lines.append(\"# Claude Code Token Usage Analysis\")\n    lines.append(f\"\\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Range: {date_range}\\n\")\n\n    # Grand totals\n    grand_input = sum(s[\"usage\"].get(\"input_tokens\", 0) for s in summaries)\n    grand_cache_create = sum(s[\"usage\"].get(\"cache_creation_input_tokens\", 0) for s in summaries)\n    grand_cache_read = sum(s[\"usage\"].get(\"cache_read_input_tokens\", 0) for s in summaries)\n    grand_output = sum(s[\"usage\"].get(\"output_tokens\", 0) for s in summaries)\n    grand_total = sum(s[\"total_tokens\"] for s in summaries)\n    total_sessions = sum(s[\"sessions\"] for s in summaries)\n    total_subagent_tokens = sum(s[\"subagent_tokens\"] for s in summaries)\n    total_subagent_count = sum(s[\"subagent_count\"] for s in summaries)\n\n    lines.append(\"## Grand Totals\\n\")\n    lines.append(f\"- **Projects**: {len(summaries)}\")\n    lines.append(f\"- **Sessions**: {total_sessions:,}\")\n    lines.append(f\"- **Total tokens**: {format_tokens(grand_total)}\")\n    lines.append(f\"  - Input: {format_tokens(grand_input)}\")\n    lines.append(f\"  - Cache creation: {format_tokens(grand_cache_create)}\")\n    lines.append(f\"  - Cache read: {format_tokens(grand_cache_read)}\")\n    lines.append(f\"  - Output: {format_tokens(grand_output)}\")\n    lines.append(f\"- **Subagent sessions**: {total_subagent_count:,} ({format_tokens(total_subagent_tokens)} tokens)\")\n    lines.append(\"\")\n\n    # Per-project breakdown\n    lines.append(\"## By Project\\n\")\n    lines.append(\"| Project | Sessions | Total | Input | Cache Create | Cache Read | Output | Subagents |\")\n    lines.append(\"|---------|----------|-------|-------|--------------|------------|--------|-----------|\")\n\n    for s in summaries:\n        u = s[\"usage\"]\n        lines.append(\n            f\"| {s['project']} | {s['sessions']} \"\n            f\"| {format_tokens(s['total_tokens'])} \"\n            f\"| {format_tokens(u.get('input_tokens', 0))} \"\n            f\"| {format_tokens(u.get('cache_creation_input_tokens', 0))} \"\n            f\"| {format_tokens(u.get('cache_read_input_tokens', 0))} \"\n            f\"| {format_tokens(u.get('output_tokens', 0))} \"\n            f\"| {s['subagent_count']} ({format_tokens(s['subagent_tokens'])}) |\"\n        )\n\n    lines.append(\"\")\n\n    # Most costly sessions\n    lines.append(\"## Most Costly Sessions\\n\")\n    costly = find_costly_sessions(projects, top_n=25)\n\n    for i, (proj, session) in enumerate(costly, 1):\n        lines.append(f\"### {i}. {proj} — {format_tokens(session['total_tokens'])} tokens\")\n        lines.append(f\"- **Session**: `{session['session_id']}`\")\n        if session[\"timestamp_start\"]:\n            lines.append(f\"- **Started**: {session['timestamp_start'][:19].replace('T', ' ')}\")\n        u = session[\"usage\"]\n        lines.append(f\"- **Tokens**: input={format_tokens(u.get('input_tokens', 0))}, cache_create={format_tokens(u.get('cache_creation_input_tokens', 0))}, cache_read={format_tokens(u.get('cache_read_input_tokens', 0))}, output={format_tokens(u.get('output_tokens', 0))}\")\n        lines.append(f\"- **Subagents in session**: {len(session['subagent_sessions'])}\")\n\n        if session[\"prompts\"]:\n            lines.append(\"- **First prompt**:\")\n            first = session[\"prompts\"][0][\"text\"][:400].replace(\"\\n\", \" \")\n            lines.append(f\"  > {first}\")\n        lines.append(\"\")\n\n    # Most costly subagents\n    lines.append(\"## Most Costly Subagents\\n\")\n    costly_subs = find_costly_subagents(projects, top_n=20)\n\n    lines.append(\"| # | Project | Parent Session | Subagent File | Total Tokens | Input | Output |\")\n    lines.append(\"|---|---------|----------------|---------------|--------------|-------|--------|\")\n\n    for i, (proj, session_id, sub) in", "url": "https://wpnews.pro/news/claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and", "canonical_source": "https://gist.github.com/kieranklaassen/7b2ebb39cbbb78cc2831497605d76cc6", "published_at": "2026-04-06 20:24:13+00:00", "updated_at": "2026-05-22 02:38:27.640229+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["Claude Code"], "alternates": {"html": "https://wpnews.pro/news/claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and", "markdown": "https://wpnews.pro/news/claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and.md", "text": "https://wpnews.pro/news/claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and.txt", "jsonld": "https://wpnews.pro/news/claude-code-token-usage-analyzer-breaks-down-usage-by-project-session-and.jsonld"}}