claude-stats
Local Claude Code usage analytics. Pulls daily cost & token
totals from ccusage and per-conversation detail by
walking ~/.claude/projects/**/*.jsonl. Stores in a
single DuckDB file. Surfaces everything through one interactive
HTML dashboard.
CLI commands
Fish function at ~/.config/fish/functions/claude-stats.fish.
All read-only against ~/Documents/claude-stats/claude.duckdb
unless noted. Four subcommands plus help.
The display surface interactive
build-dashboard.sh to regenerate
/tmp/claude-stats-dashboard.html, then
xdg-opens it. Plotly charts: cost stacked-by-model,
daily token mix, cache hit rate, top tools, top skills (grouped by
plugin), top subagents broken out by name,
cost-by-model donut, top projects, context-window distribution.
Pass 30 / 90 / 365 for a
rolling day window, or month M [Y]
(M=1–12) for a single calendar month — the
year is optional and resolves to the most recent occurrence
(bounded by 365-day retention). Both modes drive the same
half-open [start, end) date filter in every query.
Maintenance non-display
ingest-daily.sh trigger.
SINCE = YYYYMMDD, default = 8 days
ago. Idempotent UPSERT — safe to re-run.
ingest-sessions.py trigger. Walks all JSONLs
and refreshes the conversation/tool/project tables.
-readonly. Reserved for
one-off debugging.
summary, month, year,
top, models, cache,
compaction, tools, repos,
sessions). They were dropped — the dashboard
covers all of them with better visual density. If you need a
specific number for scripting, use claude-stats raw "<SQL>".
How ingest-daily.sh works
Pulls per-day cost & token totals from ccusage
(Anthropic's open-source usage CLI), reshapes via jq,
UPSERTs into two tables: daily_usage
(date × model) and project_daily_usage (project × date × model,
via --instances --breakdown). Runs nightly at 02:00 via
systemd timer.
jq del(.usage.iterations)
plain + --instances --breakdown
flatten modelBreakdowns
2× INSERT OR REPLACE
project_daily_usage
Sanitize step (2026-09-16). ccusage 20.0.20 silently drops any
assistant entry whose usage.iterations[].model is null
(ccusage#1710; Claude Code
2.1.269 wrote that on 2026-09-12 and a day of Fable 5.1 cost vanished). The script now
mirrors the window's JSONL into $TMPDIR/cfg/projects/ with
jq 'del(.message.usage.iterations)' and points ccusage at it via
CLAUDE_CONFIG_DIR. ccusage sums top-level usage, so totals are identical on
unaffected data and the shim stays harmless after the upstream fix ships.
The jq reshape (one row per day × model)
# ccusage emits nested JSON per day with .modelBreakdowns[]
# jq flattens to one CSV row per (day, model)
.daily[] as $d
| $d.modelBreakdowns[]
| [
$d.date,
.modelName,
(.inputTokens // 0),
(.outputTokens // 0),
(.cacheCreationTokens // 0),
(.cacheReadTokens // 0),
(.cost // 0)
]
| @csv
The DuckDB UPSERT (idempotent)
CREATE OR REPLACE TEMP TABLE staging AS
SELECT * FROM read_csv('$CSV',
header=false,
columns={
'date': 'DATE',
'model': 'VARCHAR',
'input_tokens': 'BIGINT',
...
});
INSERT OR REPLACE INTO daily_usage
SELECT date, model, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, cost,
CURRENT_TIMESTAMP
FROM staging;
. ~/.nvm/nvm.sh) and then adds
~/.local/bin:/usr/local/bin:/usr/bin:/bin. Sourcing nvm
follows your default alias, so a Node upgrade needs
no edits. The earlier version pinned
~/.nvm/versions/node/vX/bin directly — the moment nvm
bumped Node, that dir vanished, npx 127'd, and the
nightly job died silently for days. Sourcing nvm removes the pin.
How ingest-sessions.py works
Walks every .jsonl conversation transcript under
~/.claude/projects/, parses message events &
tool-use blocks, then UPSERTs into three tables. Per-project cost
now lives in project_daily_usage (populated by
ingest-daily.sh); this script no longer touches cost.
**/*.jsonl
by path depth
count msgs, tools, ctx
conv / tools / skills
3× INSERT OR REPLACE
Path classification
Two JSONL shapes exist under ~/.claude/projects/<project>/:
- Main:
<project>/<uuid>.jsonl→kind = 'main' - Subagent:
<project>/<parent_uuid>/subagents/<agent-id>.jsonl→kind = 'subagent'withparent_session_idset
Per-line parse
Each JSONL line is a typed event. The parser tracks:
started_at/ended_at= min/maxtimestampacross linesactive_secondsv7 = capped active-time. Every messagetimestampis collected, sorted, and consecutive gaps summed with each gap capped atIDLE_CAP_SECONDS(900s = 15 min). This is hands-on time — deliberately NOTended_at − started_at, whose raw span over-counts because a resumed session appends to the samesession_idacross days (one real session spanned 622h of wall-span, far less active).cwd/gitBranch= first non-null occurrence (sticky)type=user→ incrementn_user_msgs; for main sessions, scantool_resultblocks for"agentId: <hex>"and pair the hex back to the subagent_type from the matchingAgenttool_use (see "Subagent name attribution" below)type=assistant→ incrementn_assistant_msgs; track peak context (input + cache_create + cache_read); count tool-use blocks incontent[]; for blocks withname=Skill, also countinput.skillinto a per-session skill tally; forname=Agent(subagent dispatch), record(tool_use_id → input.subagent_type)for the agentId lookup abovetype=summary→ first one wins, capped at 500 chars- Files with zero user+assistant msgs are skipped
Subagent name attribution v5
Subagent JSONLs only know their own agentId — the
human-readable type (general-purpose,
commit-writer, etc.) lives in the parent's
Agent tool_use. ingest-sessions.py does a
two-pass match:
- Pass 1: while parsing each main session, accumulate two maps —
tool_use_id → subagent_typefromAgenttool_use blocks, andagent_id → subagent_typeby regex-matching"agentId: <hex>"out of the correspondingtool_result. Merge into a global map. - Pass 2: every subagent JSONL filename is
agent-<hex>.jsonl. Strip the prefix, look up the type in the global map, write toconversations.agent_type. Subagents whose parent JSONL was already cleaned off disk stayNULL— the dashboard surfaces the unmatched count in the panel footer.
Steady-state match rate on currently-present JSONLs is ~94%. The unmatched bucket only ever shrinks as older transcripts age out of the 30-day display window.
Storage
- conversations — one row per JSONL (PK:
session_id= filename stem) - conversation_tool_usage — one row per (session, tool); aggregated
call_count - conversation_skill_usage — one row per (session, skill); skill names are
<plugin>:<name>for plugin-namespaced skills, or bare<name>for personal skills - project_daily_usage (v4) — per-(project_path, date, model) cost from
ccusage claude daily --instances --breakdown --json, populated alongsidedaily_usagein the sameingest-daily.shrun. Same 8-day rolling-rewrite discipline: rows older than 8 days are immutable. Sums todaily_usageexactly for any (date, model) where ccusage could see the JSONLs at ingest time. The dashboard's "Top projects" panel windows over this table.
claude-clean fish function runs ingest-sessions.py
before any per-project deletion, so transcripts that
are about to be wiped are still captured. The DuckDB
conversations table outlives the JSONL files.
How build-dashboard.sh works
Self-contained HTML generator. Runs ~12 DuckDB queries, embeds the results as a JSON literal in the page, ships Plotly via CDN, and lets the browser render everything client-side. No build step, no server.
-readonly -json
CSS + structure
JSON inline
Plotly renders
The 13 queries embedded as DATA
SUM(active_seconds)/3600 over main sessions (active_seconds IS NOT NULL) started that day — feeds the "Time spent" active-hours bar chart.plugin + skill for grouped/colored bars.SELECT agent_type, COUNT(*) AS sessions, SUM(n_tool_calls), AVG(n_tool_calls) FROM conversations WHERE kind='subagent' AND agent_type IS NOT NULL GROUP BY agent_type. Drives the pink horizontal-bar panel.project_daily_usage. Cost is windowed (SUM(cost) WHERE date >= window GROUP BY project_path) and reconciles to the cent against daily_usage for any date that was captured at ingest time.claude-clean'd before being captured).AMOLED theme
Color tokens used by both the dashboard and this docs site (single source
of truth in docs/_styles.css):
--bg: #000000 /* true black for OLED panels */
--panel: #0a0a0a /* card background */
--primary: #a78bfa /* electric purple — opus, accents */
--secondary: #22d3ee /* cyan — sonnet, secondary accent */
--good: #22c55e /* green — haiku, healthy state */
--warn: #f59e0b /* amber */
--bad: #ef4444 /* red — compaction risk */
--pink: #ec4899 /* fourth distinct */
Stable per-model coloring is done in JS via colorForModel(m)
— each family keeps its brand hue (opus → purple, sonnet → cyan,
haiku → green), and lightness is ranked by the version embedded in the
model id. The newest version present gets the bright brand shade; each
older sibling dims one step. A future model (e.g. opus-4-9)
slots in by its version with no code change.
Schema
Five tables. Source-of-truth at
~/Documents/pulse/claude-stats/schema.sql. Re-applied
idempotently on each install.sh run (uses
CREATE TABLE IF NOT EXISTS).
daily_usage populated by ingest-daily.sh
| column | type | notes |
|---|---|---|
| date | DATE | Day of usage; PK component |
| model | VARCHAR | e.g. claude-opus-4-7; PK component |
| input_tokens | BIGINT | Uncached input tokens |
| output_tokens | BIGINT | Generated tokens |
| cache_creation_tokens | BIGINT | Tokens written to prompt cache |
| cache_read_tokens | BIGINT | Tokens served from cache (cheap) |
| cost | DOUBLE | USD, computed by ccusage |
| ingested_at | TIMESTAMP | Last UPSERT time (UTC) |
project_daily_usage populated by ingest-daily.sh (--instances)
| column | type | notes |
|---|---|---|
| project_path | VARCHAR | Slugified project dir; PK component (matches conversations.project_path) |
| date | DATE | Day of usage; PK component |
| model | VARCHAR | PK component |
| input/output/cache_*_tokens | BIGINT | Per (project, date, model) totals |
| cost | DOUBLE | USD; SUM over project_path = daily_usage.cost for the same (date, model) when ccusage could see the JSONLs at ingest time |
| ingested_at | TIMESTAMP | Last UPSERT time (UTC) |
conversations one row per JSONL transcript
| column | type | notes |
|---|---|---|
| session_id | VARCHAR | PK; filename stem (UUID) |
| project_path | VARCHAR | Slugified parent dir |
| cwd | VARCHAR | First-seen CWD from JSONL events |
| git_branch | VARCHAR | First-seen branch |
| kind | VARCHAR | 'main' or 'subagent' |
| parent_session_id | VARCHAR | NULL for main; UUID for subagent |
| started_at / ended_at | TIMESTAMP | UTC; min/max event timestamps. Raw span (ended_at − started_at) is NOT hands-on time — resumed sessions inflate it to days; use active_seconds instead. |
| n_user_msgs / n_assistant_msgs / n_tool_calls | INTEGER | Counts |
| max_context_tokens | BIGINT | Peak context size seen in any assistant turn |
| total_output_tokens v6 | BIGINT | Nullable. Sum of usage.output_tokens across all assistant messages. NULL for rows ingested before v6 whose JSONLs were already cleaned. Queries must filter IS NOT NULL. |
| active_seconds v7 | BIGINT | Nullable. Capped active-time (hands-on seconds): Σ min(gap between consecutive message timestamps, 900s). Powers the "Active time" card, the "Time spent" daily chart, and the sessions-table active column. NULL when the JSONL was cleaned before v7. Queries must filter IS NOT NULL. |
| model | VARCHAR | Model in use at the peak-context turn |
| summary | VARCHAR | First type=summary event, capped at 500 chars |
| agent_type v5 | VARCHAR | Nullable. For kind='subagent': the subagent_type from the parent's Agent tool_use (e.g. general-purpose, commit-writer). NULL for main sessions and for subagents whose parent JSONL is missing at ingest time. Queries must filter WHERE kind='subagent' AND agent_type IS NOT NULL. |
conversation_tool_usage
| column | type | notes |
|---|---|---|
| session_id, tool_name | VARCHAR | Composite PK |
| call_count | INTEGER | Total invocations of that tool in that session |
conversation_skill_usage
| column | type | notes |
|---|---|---|
| session_id, skill_name | VARCHAR | Composite PK. skill_name is <plugin>:<name> or bare <name> |
| call_count | INTEGER | Total invocations of that skill in that session |