/cache-report — Prompt Cache Hit Rate Health Check
Anthropic declares SEVs internally when cache hit rate drops. We do the same — except instead of paging, we surface a report.
What you produce
A markdown report covering:
- Last 7 days — avg hit rate, p50, p10 (worst sessions), trend vs previous 7 days
- Last 24 hours — same, more granular
- By model — Opus vs Sonnet vs Haiku breakdown
- Anomalies — sessions where hit rate dropped below 70% (logged in
cache-anomalies.jsonl) - Top 3 expensive sessions — by total cost; root-cause hypothesis for each
- Top 3 cache-cold sessions — sessions with high cache_creation but low cache_read; identify why (new CLAUDE.md? new ruleset? clean compact?)
How to compute
Read ~/.claude/telemetry/cache-stats.jsonl. Each line is a session-end record:
{"ts": "...", "session_id": "...", "model": "claude-opus-4-8", "input_tokens": 12345, "cache_read_input_tokens": 89012, "cache_creation_input_tokens": 3456, "cache_hit_rate": 0.85, "cost_usd": 1.23, ...}
CRITICAL — rows are cumulative snapshots, not deltas. The SessionEnd hook
(_log_cache_stats.py) re-reads the whole transcript on every session end and
writes the session's running total. A paused/resumed session therefore emits
several rows carrying the same growing cost_usd/*_tokens (the final two are
often byte-identical). Summing cost_usd across raw rows multiplies the cost
of any multi-end session — one 1M-context Opus session logged 4× inflated the
24h estimate ~3.7× on 2026-07-11. Always reduce to ONE row per session_id —
the max (final) cumulative snapshot — before aggregating cost or averaging hit
rates. cache-report-daily.sh does this; match it here.
Aggregate with jq or python. Don't load the whole file into Claude's context — process via shell, summarize.
# Quick averages — dedup to per-session final snapshot before aggregating.
python3 -c "
import json, statistics
from datetime import datetime, timezone
recs = [json.loads(l) for l in open('$HOME/.claude/telemetry/cache-stats.jsonl')]
now = datetime.now(timezone.utc)
def parse_ts(r):
return datetime.fromisoformat(r['ts'].replace('Z','+00:00'))
def finals(rs):
# one row per session_id = the largest (final) cumulative snapshot
by = {}
for r in rs:
sid = r.get('session_id')
if sid not in by or (r.get('cost_usd') or 0) > (by[sid].get('cost_usd') or 0):
by[sid] = r
return list(by.values())
last7 = finals([r for r in recs if (now - parse_ts(r)).days < 7])
last24 = finals([r for r in recs if (now - parse_ts(r)).total_seconds() < 86400])
def stats(rs):
if not rs: return None
rates = [r.get('cache_hit_rate') or 0 for r in rs]
costs = [r.get('cost_usd') or 0 for r in rs]
return {
'n_sessions': len(rs),
'mean_hit_rate': statistics.mean(rates),
'p50_hit_rate': statistics.median(rates),
'p10_hit_rate': sorted(rates)[max(0,len(rates)//10)],
'total_cost_usd': round(sum(costs), 2),
}
print('7d:', stats(last7))
print('24h:', stats(last24))
"
Threshold guidance
| Hit rate | Status | Action |
|---|---|---|
| ≥ 90% | Excellent | None |
| 80–90% | Healthy | None |
| 70–80% | Watch | Investigate top cold sessions |
| 60–70% | Degraded | Audit recent CLAUDE.md/rules edits, hook config |
| < 60% | Broken | Stop. Find what changed. SEV. |
Common root causes for a drop
- CLAUDE.md edit — invalidates the prefix until cache rebuilds (one or two sessions of cold cost is normal; sustained drop is a problem)
- New rule loaded — same as above for
.claude/rules/*.md - MCP server churn — added/removed servers changes tool descriptions
--baremode session that didn't populate cache — expected for headless eval runs; filter these out- Compaction without re-injection —
SessionStart matcher: compacthook missing or broken - Skill content bloat — invoking many skills mid-session can push old ones out
Output format
# Cache Report — <date>
## Headline
- 7d hit rate: 87% (↑ from 82% prior 7d)
- 24h hit rate: 91%
- Anomalies: 2 sessions below 70%
## By model
| Model | Sessions | Mean | P10 |
|---|---|---|---|
| opus | 42 | 89% | 72% |
| sonnet | 18 | 84% | 68% |
## Anomalies (24h)
- session=abc123: 64% hit rate, $4.21 cost. Likely cause: <hypothesis>
...
## Recommendations
- ...
Keep total report under 500 words. The data is the value, not the prose.