cctrack 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,10 +35,11 @@ This is a single-module CLI tool. All logic lives in `src/cctrack/__init__.py`:
35
35
  1a. **Energy model** (`ENERGY_WH_PER_MTOK`, `estimate_energy_wh`) - Opt-in (`--energy`) order-of-magnitude estimate keyed off each rate entry's `tier`. Deliberately NOT a function of price: both cache-write TTLs use one figure because a 1h write costs more money but the same compute.
36
36
  2. **File discovery** (`default_dirs`, `discover_files`) - Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for `.jsonl` files.
37
37
  3. **Remote aggregation** (`fetch_remote_jsonl`, `parse_remote_events`, `fetch_remote_hook_data`, `parse_remote_hook_data`) - SSHs to hosts, cats all JSONL logs plus statusline hook data (statusline files, monthly summaries, session baselines) with file separators, parses locally.
38
- 3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas`, `_cost_increment`) - The hook is authoritative for **cost only**. Its `input_tokens`/`output_tokens`/`cache_*` fields are context-window SNAPSHOTS (`input_tokens` ≈ `cache_read + cache_write`; `output_tokens` is just the last message's), not cumulative — never use them for token counts. `cost_usd` is a per-session running total, but it RESTARTS on resume/compaction and a render can report 0, so deltas walk every row chronologically and treat a meaningful drop as a restart.
38
+ 3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas`) - The hook gives **cost only**. Its `input_tokens`/`output_tokens`/`cache_*` fields are context-window SNAPSHOTS (`input_tokens` ≈ `cache_read + cache_write`; `output_tokens` is just the last message's), not cumulative — never use them for token counts. `cost_usd` is a session-lifetime cumulative that is unreliable in two ways handled by `compute_hook_deltas`: (1) it can already be large the first time a session is observed (a long-running/unattended session that only rendered its statusline partway through), so the **first observation is a baseline that is NOT counted** — booking a session's pre-existing cumulative onto its first-seen day is what produced a bogus $3,370 day; (2) it **drops** on resume/compaction (or when parallel processes share one session id), so a drop **rebaselines without counting the new level** — otherwise a day's booked spend can exceed the counter's own peak, which is impossible for real money. Net rule: **book only observed upward increments**; a rolled-up baseline seeds `prev` so already-counted days aren't recounted.
39
39
  4. **JSONL parsing** (`parse_lines`, `parse_events`, `dedup_events`) - Extracts `assistant` events with token usage. Deduplicates by `requestId` across all of a host's files (last event wins) — resumed sessions (`--resume`/`--continue`) repeat events with the same `requestId` in new files. Events without `requestId` are never deduped.
40
40
  5. **Aggregation** (`aggregate`) - Buckets events into daily and monthly summaries with token counts and costs.
41
41
  6. **Output** (`print_report`) - Prints current month-to-date summary with projections, daily breakdown, and previous month summaries. Cost is composed **per source** (one source per machine): hook cost where a source has statusline coverage, accuracy-factor-adjusted JSONL cost otherwise, summed per date. The accuracy factor is measured per source over dates strictly after that source's hook install day.
42
+ 6a. **Explore** (`print_explore`, `--explore DATE`) - Drills into one day: dollar decomposition by token class, then by model / project / session / machine. Each JSONL event carries its `project` (from `cwd`), `session_id`, `source` (host) and `git_branch` for attribution. Per-session JSONL estimate is shown against the hook's authoritative per-session cost; a >15% day-total divergence is flagged so an inflated estimate (incomplete logs, unknown model) is distinguishable from real spend.
42
43
 
43
44
  Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script).
44
45
 
@@ -48,7 +49,7 @@ Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script)
48
49
  - Deduplication is global per host by `requestId` (last wins) - resumed sessions replay history into new files and must not double count. Cross-host `requestId` collisions can't occur (distinct API calls).
49
50
  - Hook data is authoritative for **cost only**; every token count comes from the JSONL logs. Never mix the two in one figure - the hook's token fields are snapshots and would double count against JSONL cache columns.
50
51
  - Never let one machine's hook coverage suppress another machine's JSONL cost - compose per source.
51
- - Cumulative counters from the hook are not monotonic. Any code differencing `cost_usd` must handle restarts (resume/compaction) and zero-valued renders, or whole days silently vanish.
52
+ - Cumulative counters from the hook are not monotonic and don't start at zero when first observed. `compute_hook_deltas` books only observed upward increments (first observation and post-drop levels are baselines, never counted), so a day's hook cost can never exceed the counter's peak and a session's pre-observation history is never mis-dated onto its first-seen day. Test fixtures must start sessions at ~$0 to mirror real statusline behavior.
52
53
  - `session-baselines.json` carries each session's rolled-up cumulative across the 90-day retention boundary so straddling sessions aren't re-counted from zero.
53
54
  - Build system is Hatch (`hatchling`)
54
55
  - Requires Python 3.10+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctrack
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
5
5
  Project-URL: Homepage, https://github.com/rappdw/cctrack
6
6
  Project-URL: Repository, https://github.com/rappdw/cctrack
@@ -84,6 +84,32 @@ Daily breakdown:
84
84
  4. Calculates cost using Anthropic's published rates per model
85
85
  5. Prints daily breakdown with input/output/cache token splits and monthly summaries
86
86
 
87
+ ## Explore a day
88
+
89
+ When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
90
+
91
+ ```bash
92
+ cctrack --explore 2026-08-03 # a specific day
93
+ cctrack --explore yesterday # or 'today'
94
+ cctrack --explore yesterday --remote air dgx # across machines
95
+ ```
96
+
97
+ It reports, for that day:
98
+
99
+ - **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
100
+ - **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
101
+ - **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
102
+
103
+ The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
104
+
105
+ The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
106
+
107
+ ```bash
108
+ cctrack --explore 2026-08-03 --project team-inbox
109
+ ```
110
+
111
+ This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
112
+
87
113
  ## Remote aggregation
88
114
 
89
115
  With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
@@ -59,6 +59,32 @@ Daily breakdown:
59
59
  4. Calculates cost using Anthropic's published rates per model
60
60
  5. Prints daily breakdown with input/output/cache token splits and monthly summaries
61
61
 
62
+ ## Explore a day
63
+
64
+ When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
65
+
66
+ ```bash
67
+ cctrack --explore 2026-08-03 # a specific day
68
+ cctrack --explore yesterday # or 'today'
69
+ cctrack --explore yesterday --remote air dgx # across machines
70
+ ```
71
+
72
+ It reports, for that day:
73
+
74
+ - **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
75
+ - **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
76
+ - **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
77
+
78
+ The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
79
+
80
+ The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
81
+
82
+ ```bash
83
+ cctrack --explore 2026-08-03 --project team-inbox
84
+ ```
85
+
86
+ This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
87
+
62
88
  ## Remote aggregation
63
89
 
64
90
  With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnose a cctrack --explore day and show what each session really spent.
3
+
4
+ The hook's `total_cost_usd` is a session-lifetime cumulative that (a) can be
5
+ large the moment cctrack first sees a long-running session, and (b) drops on
6
+ resume/compaction. cctrack 0.6.0 mis-attributed both: it booked a session's
7
+ whole pre-existing cumulative onto the first day it saw the session, and it
8
+ re-added post-drop levels as fresh spend — so a session whose counter never
9
+ passed $1,086 could be reported at $2,083, and a day's total could balloon far
10
+ above real spend.
11
+
12
+ This script reads the raw statusline data for a date and, per session, prints:
13
+
14
+ * the cost_usd trajectory,
15
+ * BUGGY: what cctrack ≤0.6.0 booked (first level + every post-drop level),
16
+ * FIXED: what cctrack now books — only observed increments, never the
17
+ standing level (== the honest spend cctrack can see on that day).
18
+
19
+ If FIXED is far below BUGGY, the old report was inflated. FIXED is what the
20
+ corrected cctrack reports; any spend from earlier days the hook didn't capture
21
+ is genuinely unattributable and isn't booked on this day.
22
+
23
+ Usage:
24
+ python3 scripts/diagnose_session_cost.py <YYYY-MM-DD> [session_prefix ...]
25
+
26
+ # every session that has data that day:
27
+ python3 scripts/diagnose_session_cost.py 2026-08-03
28
+
29
+ # just specific ones (any unique prefix of the session id works):
30
+ python3 scripts/diagnose_session_cost.py 2026-08-03 34dfdf14 63fd9dae 7c112e0a
31
+
32
+ Reads ~/.claude/cctrack and any ~/.sandy/sandboxes/*/claude/cctrack. Stdlib only.
33
+ """
34
+ import glob
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+
39
+
40
+ def buggy_increment(cur: float, prev: float) -> float:
41
+ """What cctrack <= 0.6.0 did: a meaningful drop counted the new value in
42
+ full (and prev started at 0, so the first reading counted in full too)."""
43
+ if cur < prev - max(1e-6, abs(prev) * 0.001):
44
+ return max(0.0, cur)
45
+ return max(0.0, cur - prev)
46
+
47
+
48
+ def hook_dirs() -> list[Path]:
49
+ dirs = [Path.home() / ".claude" / "cctrack"]
50
+ dirs += [Path(p) for p in glob.glob(
51
+ str(Path.home() / ".sandy" / "sandboxes" / "*" / "claude" / "cctrack"))]
52
+ return [d for d in dirs if d.exists()]
53
+
54
+
55
+ def load_rows(date_str: str) -> dict[str, list[tuple[str, float]]]:
56
+ """Return {session_id: [(ts, cost_usd), ...]} for the given date."""
57
+ rows: dict[str, list[tuple[str, float]]] = {}
58
+ for d in hook_dirs():
59
+ for f in glob.glob(str(d / f"statusline-{date_str}.jsonl")):
60
+ try:
61
+ lines = Path(f).read_text(errors="replace").splitlines()
62
+ except OSError:
63
+ continue
64
+ for line in lines:
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ e = json.loads(line)
70
+ except (json.JSONDecodeError, ValueError):
71
+ continue
72
+ sid = e.get("session_id", "")
73
+ ts = e.get("_ts", "")
74
+ if not sid or not ts:
75
+ continue
76
+ rows.setdefault(sid, []).append((ts, float(e.get("cost_usd", 0) or 0)))
77
+ return rows
78
+
79
+
80
+ def analyze(sid: str, seq: list[tuple[str, float]]) -> dict:
81
+ seq.sort()
82
+ costs = [c for _, c in seq]
83
+
84
+ # BUGGY (<= 0.6.0): prev starts at 0; drops re-add the full new level.
85
+ buggy = prev = 0.0
86
+ drops = 0
87
+ for c in costs:
88
+ if c < prev - max(1e-6, abs(prev) * 0.001):
89
+ drops += 1
90
+ buggy += buggy_increment(c, prev)
91
+ prev = c
92
+
93
+ # FIXED: first reading is a baseline (not counted); a drop rebaselines
94
+ # (not counted); only genuine upward increments are booked.
95
+ fixed = 0.0
96
+ prev = None
97
+ for c in costs:
98
+ if prev is None:
99
+ prev = c
100
+ elif c < prev - max(1e-6, abs(prev) * 0.001):
101
+ prev = c
102
+ else:
103
+ fixed += c - prev
104
+ prev = c
105
+
106
+ return {
107
+ "sid": sid,
108
+ "renders": len(costs),
109
+ "min": min(costs),
110
+ "max": max(costs),
111
+ "first": costs[0],
112
+ "last": costs[-1],
113
+ "drops": drops,
114
+ "buggy": buggy,
115
+ "fixed": fixed,
116
+ "costs": costs,
117
+ }
118
+
119
+
120
+ def main() -> int:
121
+ if len(sys.argv) < 2:
122
+ print(__doc__)
123
+ return 2
124
+
125
+ date_str = sys.argv[1]
126
+ prefixes = sys.argv[2:]
127
+
128
+ rows = load_rows(date_str)
129
+ if not rows:
130
+ print(f"No statusline data found for {date_str} in:")
131
+ for d in hook_dirs() or ["(no hook directories exist)"]:
132
+ print(f" {d}")
133
+ return 1
134
+
135
+ selected = {sid: seq for sid, seq in rows.items()
136
+ if not prefixes or any(sid.startswith(p) for p in prefixes)}
137
+ if not selected:
138
+ print(f"No sessions matching {prefixes} on {date_str}. "
139
+ f"Sessions present: {', '.join(s[:12] for s in rows)}")
140
+ return 1
141
+
142
+ results = [analyze(sid, seq) for sid, seq in selected.items()]
143
+ results.sort(key=lambda r: -r["buggy"])
144
+
145
+ grand_buggy = grand_fixed = 0.0
146
+ for r in results:
147
+ grand_buggy += r["buggy"]
148
+ grand_fixed += r["fixed"]
149
+ inflation = (r["buggy"] / r["fixed"]) if r["fixed"] > 0.005 else float("inf")
150
+ tag = "" if inflation < 1.15 else f" ← inflated {inflation:.0f}×" if inflation != float("inf") else " ← booked from nothing"
151
+ print(f"\n=== {r['sid'][:12]} ({r['renders']} renders) ===")
152
+ print(f" cost_usd first={r['first']:.2f} min={r['min']:.2f} "
153
+ f"max={r['max']:.2f} last={r['last']:.2f} drops={r['drops']}")
154
+ print(f" BUGGY (cctrack ≤0.6.0 booked on this day): ${r['buggy']:.2f}{tag}")
155
+ print(f" FIXED (real observed spend this day): ${r['fixed']:.2f}")
156
+ shown = " ".join(f"{c:.1f}" for c in r["costs"][:40])
157
+ print(f" trajectory: {shown}" + (" …" if len(r["costs"]) > 40 else ""))
158
+
159
+ print(f"\n{'─' * 60}")
160
+ print(f"selected sessions — buggy total: ${grand_buggy:.2f} fixed total: ${grand_fixed:.2f}")
161
+ if grand_fixed > 0.005 and grand_buggy / grand_fixed > 1.15:
162
+ print(f"The old report over-stated these sessions by "
163
+ f"{grand_buggy / grand_fixed:.1f}×. The FIXED column is the honest")
164
+ print("spend cctrack can see for this day. Any earlier-day spend the hook")
165
+ print("never captured is unattributable and is not booked here.")
166
+ else:
167
+ print("Buggy and fixed agree — these sessions were attributed correctly.")
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Trace where a cctrack --explore project label actually comes from.
3
+
4
+ `--explore` labels each session by the basename of the `cwd` recorded on its
5
+ JSONL events (e.g. a cwd of /workspace/team-inbox shows as "team-inbox"). That
6
+ basename can be a container/automation path that doesn't match any sandbox or
7
+ directory you recognize. This script shows the FULL cwd, the git branch, the
8
+ session id, and the exact log file on disk for a project or session, so you can
9
+ see where the events physically are.
10
+
11
+ Usage:
12
+ python3 scripts/trace_project.py [YYYY-MM-DD] [name_or_prefix ...]
13
+
14
+ # everything logged yesterday, grouped by project:
15
+ python3 scripts/trace_project.py 2026-08-03
16
+
17
+ # just the mystery project (matches project basename OR session-id prefix):
18
+ python3 scripts/trace_project.py 2026-08-03 team-inbox
19
+
20
+ # a whole date range is not supported; omit the date to scan all logs:
21
+ python3 scripts/trace_project.py team-inbox
22
+
23
+ Scans ~/.claude/projects and ~/.sandy/sandboxes/*/... for *.jsonl. Stdlib only.
24
+ """
25
+ import glob
26
+ import json
27
+ import sys
28
+ from collections import defaultdict
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+
33
+ def is_date(tok: str) -> bool:
34
+ try:
35
+ datetime.strptime(tok, "%Y-%m-%d")
36
+ return True
37
+ except ValueError:
38
+ return False
39
+
40
+
41
+ def scan_dirs() -> list[Path]:
42
+ home = Path.home()
43
+ roots = [home / ".claude" / "projects"]
44
+ sandy = home / ".sandy" / "sandboxes"
45
+ if sandy.exists():
46
+ roots.append(sandy)
47
+ files: list[Path] = []
48
+ for root in roots:
49
+ if root.exists():
50
+ files += [Path(p) for p in glob.glob(str(root / "**" / "*.jsonl"), recursive=True)]
51
+ return files
52
+
53
+
54
+ def main() -> int:
55
+ args = sys.argv[1:]
56
+ date_str = None
57
+ if args and is_date(args[0]):
58
+ date_str = args[0]
59
+ args = args[1:]
60
+ filters = args # project basenames or session-id prefixes
61
+
62
+ def matches(project: str, sid: str) -> bool:
63
+ if not filters:
64
+ return True
65
+ return any(project == f or project.endswith(f) or sid.startswith(f) for f in filters)
66
+
67
+ # key: (cwd, sessionId, file) -> {count, models, branches, first, last}
68
+ groups: dict[tuple, dict] = defaultdict(
69
+ lambda: {"count": 0, "models": set(), "branches": set(), "first": "", "last": ""})
70
+
71
+ files = scan_dirs()
72
+ for f in files:
73
+ try:
74
+ lines = f.read_text(errors="replace").splitlines()
75
+ except OSError:
76
+ continue
77
+ for line in lines:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ e = json.loads(line)
83
+ except (json.JSONDecodeError, ValueError):
84
+ continue
85
+ if e.get("type") != "assistant":
86
+ continue
87
+ ts = e.get("timestamp", "")
88
+ if date_str and not ts.startswith(date_str):
89
+ continue
90
+ cwd = e.get("cwd", "")
91
+ project = Path(cwd).name if cwd else "(unknown)"
92
+ sid = e.get("sessionId", "")
93
+ if not matches(project, sid):
94
+ continue
95
+ g = groups[(cwd, sid, str(f))]
96
+ g["count"] += 1
97
+ model = e.get("message", {}).get("model", "")
98
+ if model:
99
+ g["models"].add(model)
100
+ if e.get("gitBranch"):
101
+ g["branches"].add(e["gitBranch"])
102
+ if ts:
103
+ g["first"] = ts if not g["first"] else min(g["first"], ts)
104
+ g["last"] = max(g["last"], ts)
105
+
106
+ if not groups:
107
+ where = f" on {date_str}" if date_str else ""
108
+ filt = f" matching {filters}" if filters else ""
109
+ print(f"No assistant events{filt}{where} found in ~/.claude/projects or ~/.sandy/sandboxes.")
110
+ return 1
111
+
112
+ # Group the display by project basename.
113
+ by_project: dict[str, list[tuple]] = defaultdict(list)
114
+ for (cwd, sid, f), g in groups.items():
115
+ by_project[Path(cwd).name if cwd else "(unknown)"].append((cwd, sid, f, g))
116
+
117
+ for project in sorted(by_project):
118
+ rows = by_project[project]
119
+ total = sum(g["count"] for *_ , g in rows)
120
+ print(f"\n=== project '{project}' ({total} assistant events, "
121
+ f"{len(set(s for _, s, _, _ in rows))} sessions) ===")
122
+ # distinct cwds under this basename (reveals collisions / container paths)
123
+ for cwd in sorted(set(c for c, _, _, _ in rows)):
124
+ print(f" cwd: {cwd or '(empty)'}")
125
+ for cwd, sid, f, g in sorted(rows, key=lambda r: -r[3]["count"]):
126
+ branch = ",".join(sorted(g["branches"])) or "-"
127
+ models = ",".join(sorted(m.replace("claude-", "") for m in g["models"])) or "-"
128
+ print(f" session {sid[:12]} {g['count']:>4} ev branch={branch} model={models}")
129
+ print(f" {g['first'][:19]} … {g['last'][:19]}")
130
+ print(f" file: {f}")
131
+ print()
132
+ print("The `cwd` line is the working directory Claude Code recorded — that is")
133
+ print("the real source of the project label. If it's a container/automation")
134
+ print("path (e.g. /workspace/...), the run happened inside that environment,")
135
+ print("not a host directory or sandbox of that name.")
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())