ccxray usage
September 4, 2026 · View on GitHub
ccxray usage summarizes your logged Claude Code / Codex traffic straight from
~/.ccxray/logs/index.ndjson — no running server required. The --json output
is an agent-facing contract: small (target <4KB) with a stable shape —
the key set and field types below don't change without a deliberate, changelogged
update. Field values are derived from the index plus your arguments, with a few
inputs resolved at run time: --last is relative to wall-clock now and
--cwd ~ expands to the local home dir (see Filter semantics),
and skills[].scope is read from the local filesystem (see the skills
note). So treat the shape as the contract; expect values to track the index
and the runtime environment.
The single source of truth for the shapes below is
server/usage.js: analyze() builds the single-scope
object, while run() assembles the multi-cwd comparison object and the error
object. This
doc mirrors them; if they ever disagree, the code wins — and that disagreement
is a bug to fix here.
Contract note. Because agents consume
--json, the field set, types, and the three top-level shapes (single-scope object, multi-cwd object, error object) are treated as a contract. Shape changes must be deliberate and noted in the changelog below. Theusage --json shape contractblock intest/usage.test.jslocks every section's exact key set and field types (plus the multi-cwd and error shapes), so an accidental field add/remove fails CI — a deliberate change must update both that test and this doc in the same commit. A separate test guards the<5KBsize ceiling (the<4KBabove is the working target that sits under that ceiling).
Output modes at a glance
--json emits a single line of compact JSON.stringify output to stdout.
Which of three shapes you get depends on the arguments:
| Condition | Shape | Exit |
|---|---|---|
Default (0 or 1 --cwd) | Single-scope object | 0 |
--cwd a,b (2+ values) | Multi-cwd object | 0 |
| No index / no matching entries | Error object | 1 |
Rounded numeric fields are rounded to at most the number of decimals noted
per field. The value is coerced back to a JSON number (+x.toFixed(n)), so
trailing zeros are dropped — you get 0.5, not 0.50, and an exact 0 rather
than 0.00. Treat the per-field precision as a maximum, not a fixed width;
the rounding cap is the contract.
1. Single-scope object (default)
{
"meta": { "totalEntries": 0, "totalSessions": 0, "totalCost": 0,
"retentionDays": 14, "retentionCutoff": "YYYY-MM-DD",
"retentionWarning": "older history may have been removed; totals are a lower bound",
"timeRange": { "from": "ISO|null", "to": "ISO|null" } },
"sessions": { "count": 0, "byProvider": { "<provider>": 0 },
"subagentRatio": 0,
"turnDistribution": { "min": 0, "median": 0, "p75": 0, "max": 0 },
"topSessions": [ /* ≤10, see below */ ] },
"models": [ /* ≤10, see below */ ],
"tools": { "totalCalls": 0, "top": [ /* ≤7 (all with --tools) */ ],
"failRate": 0 },
"skills": [ /* see below; may be [] */ ],
"prompts": { "hashStability": { "sysHash": {…}, "toolsHash": {…}, "coreHash": {…} } },
"cache": { "hitRate": 0, "totalInputTokens": 0,
"totalOutputTokens": 0, "totalCacheReadTokens": 0 },
"gapCache": [ /* only non-empty buckets */ ]
}
meta
| Field | Type | Notes |
|---|---|---|
totalEntries | number | Count of entries (turns) after all filters. |
totalSessions | number | Distinct sessionId count. Entries with no session id collapse into one "unknown" bucket, which counts here. |
totalCost | number | Sum of per-turn cost, USD, 2 dp. |
retentionDays | number | null | Active LOG_RETENTION_DAYS value. 0 or below disables retention. null means the setting is non-numeric or beyond the supported window (magnitude over 36500 days ≈ 100 years, i.e. a "never prune" setting); retention is inactive, so no cutoff or warning is produced. |
retentionCutoff | string | null | Taipei-local YYYY-MM-DD cutoff used by log pruning; null when retention is disabled. |
retentionWarning | string (conditional) | Present only when the requested range starts before retentionCutoff: older history may have been removed, so all totals are a lower bound. The no---last all-time query always qualifies when retention is enabled. |
timeRange.from | string | null | Earliest receivedAt as ISO 8601, or null if no timestamps. |
timeRange.to | string | null | Latest receivedAt as ISO 8601, or null. |
sessions
| Field | Type | Notes |
|---|---|---|
count | number | Same value as meta.totalSessions. |
byProvider | object | Map of provider → turn count (e.g. { "anthropic": 42 }). Missing provider → "unknown". Counts turns, not sessions. |
subagentRatio | number | subagent turns / totalEntries, 3 dp, range 0–1. |
turnDistribution | object | { min, median, p75, max } of turns-per-session. Percentiles take the value at zero-based index floor(q × count) (capped at the last element), so e.g. with 2 sessions median is the upper value, not an average. |
topSessions | array | Up to 10 sessions, sorted by cost descending. The synthetic "unknown" and "direct-api" sessions are excluded here (they remain in meta/sessions.count). |
Each topSessions[] element:
| Field | Type | Notes |
|---|---|---|
sessionId | string | Full session id. |
turns | number | Turns in the session. |
cost | number | USD, 2 dp. |
costAgg | object | Confidence fold for cost, always present: {count, fallbackCount, fallbackCost, unknownCount}, all numbers. fallbackCount/fallbackCost cover turns priced from a default rate; unknownCount counts turns that contributed nothing to cost, so a non-zero value means cost is a lower bound. Render cost through this fold (formatAggCost/formatAggCostText, ADR 0017) — a consumer of this JSON cannot see the turns, so it cannot re-derive the fold, and printing cost alone reports a fabricated or under-counted total as an exact one. |
durationMin | number | First→last receivedAt span in minutes, 1 dp. 0 if fewer than 2 timestamped turns. |
title | string | null | Best non-continuation title, truncated to 40 chars. null if none. |
model | string | The session's dominant model (most turns), as a single string — not a map. "unknown" if absent. |
provider | string | Provider of the first turn. |
models
Array of up to 10 models, sorted by turns descending.
| Field | Type | Notes |
|---|---|---|
model | string | Model id. "unknown" if absent. |
turns | number | Turns on this model. |
cost | number | USD, 2 dp. |
costShare | number | model cost / totalCost, 3 dp, range 0–1. |
tools
| Field | Type | Notes |
|---|---|---|
totalCalls | number | Total tool invocations across all entries. |
top | array | [{ name, count }], sorted by count descending. Capped at 7 by default; --tools lifts the cap to all tools. |
failRate | number | entries with a tool failure / totalEntries, 3 dp. Note the denominator is turns, not tool calls. |
skills
Array of skills, sorted by invocations descending. May be [].
| Field | Type | Notes |
|---|---|---|
name | string | Skill name (may be namespaced, e.g. superpowers:brainstorming). |
invocations | number | Total times invoked. |
loads | number | null | Distinct sessions that invoked it (a proxy for unique "loads"). null only on the synthetic legacy row. |
scope | string | null | "user", "project", "plugin", or null. Resolved by scanning skill directories at analysis time, so a since-deleted skill reads as null. |
Legacy row. Entries logged before per-skill tracking (skillCalls) existed
contribute a single appended row { "name": "(pre-tracking)", "invocations": N, "loads": null, "scope": null }, where N is the count of generic Skill tool
calls from those old entries. It is pushed after the sorted skills, so it is not
part of the descending-invocations order.
prompts.hashStability
How often the system prompt / tools / core prompt change between adjacent turns of a session — a window into prompt-cache churn.
"hashStability": {
"sysHash": { "changeRate": 0, "pairs": 0, "label": "never" },
"toolsHash": { "changeRate": 0, "pairs": 0, "label": "never" },
"coreHash": { "changeRate": 0, "pairs": 0, "label": "never" }
}
| Field | Type | Notes |
|---|---|---|
changeRate | number | changed pairs / pairs, 4 dp, range 0–1. |
pairs | number | Adjacent turn pairs (within a session) where both turns carry that hash. |
label | string | Bucketed changeRate: > 0.5 → every-turn, > 0.1 → frequent, > 0.01 → occasional, > 0 → rare, = 0 → never. |
cache
| Field | Type | Notes |
|---|---|---|
hitRate | number | cache-read tokens / all input tokens, 3 dp, range 0–1. |
totalInputTokens | number | All input tokens = input + cache_creation + cache_read, summed. (Not just input_tokens.) |
totalOutputTokens | number | Sum of output_tokens. |
totalCacheReadTokens | number | Sum of cache_read_input_tokens. |
gapCache
Cache hit rate bucketed by the idle gap before a turn (time since the previous turn finished). Only buckets that contain at least one measured gap appear — empty buckets are omitted, so this array can have 0–5 elements.
| Field | Type | Notes |
|---|---|---|
gap | string | One of "<30s", "30s-5m", "5-15m", "15-60m", ">60m". |
turns | number | Inter-turn gaps measured in this bucket (not sessions). |
avgHitRate | number | Mean per-turn cache hit rate in the bucket, 3 dp. |
medianHitRate | number | Median per-turn cache hit rate, 3 dp. |
2. Multi-cwd comparison object
When two or more --cwd values are given (e.g. --cwd proj-a,proj-b), the
output is a per-project comparison object instead of the single-scope object.
projects is sorted by cost descending; meta carries the retention
disclosure for the query as a whole:
{
"projects": [
{ "cwd": "/work/project-alpha", "cost": 0.80, "sessions": 1, "turns": 2, "cacheHit": 0.86 },
{ "cwd": "/work/project-beta", "cost": 0.30, "sessions": 1, "turns": 2, "cacheHit": 0.78 }
],
"meta": {
"retentionDays": 14, "retentionCutoff": "YYYY-MM-DD",
"retentionWarning": "older history may have been removed; totals are a lower bound"
}
}
meta holds exactly the three retention fields documented for the single-scope
meta, with the same semantics. Retention is a property of the query, not of
any one project, so it appears once at the top level and never on a projects[]
row — these per-project totals sit behind the same window as the single-scope
ones and would otherwise be presented as exact.
| Field | Type | Notes |
|---|---|---|
cwd | string | Working directory (group key). Always a real path — the --cwd filter that triggers this mode drops entries with no cwd, so no "unknown" row appears here. |
cost | number | That group's own single-scope meta.totalCost, 2 dp. Not a field of the top-level meta above, which carries only the retention triple. |
sessions | number | That group's own single-scope meta.totalSessions. |
turns | number | That group's own single-scope meta.totalEntries. |
cacheHit | number | That group's own cache.hitRate, 3 dp. |
The grouping is over the entries that already passed --last/--cwd/--session
filtering, so every matched cwd that survived appears as one row.
3. Error object
On failure, --json prints a single error object and exits with code 1:
{ "error": "<reason>", "hint": "<actionable hint>" }
error | When | hint |
|---|---|---|
"no logs found" | index.ndjson does not exist under the active CCXRAY_HOME. | Names the resolved home so you can fix CCXRAY_HOME. |
"no matching entries" | The index exists but no entry survived the filters. | Suggests loosening a specific filter (or notes the index is empty). |
Without --json, the same conditions print a human message to stderr (still
exit 1). Invalid --last durations also exit 1 with a stderr message, before any
JSON is produced.
Filter semantics
These apply to both human and --json output.
--last <d/h/m>
Keep entries with receivedAt >= now - duration. Forms: 7d, 24h, 30m
(days / hours / minutes). Anything else exits 1 with an error. Applied first.
--cwd <path> (matching)
Comma-separated or repeated. Each value matches one of two ways:
- Absolute (
/…) or~-rooted path → path-bound prefix.~expands to your home dir first. The match is subtree-aware:/work/projmatches/work/projand/work/proj/sub, but not the sibling/work/proj-other. - Anything else → case-insensitive substring against the full cwd. A leading
./is stripped, so./foobehaves likefoo.
Giving 2+ cwd values switches the output to the multi-cwd comparison object.
--session <id> (matching)
Comma-separated or repeated. Resolved after --last/--cwd, so the aliases
operate on the already-filtered scope. Each value is one of:
- Alias —
latest(newest byreceivedAtin scope) orcostliest(highest summed cost in scope). - UUID prefix —
e.sessionId.startsWith(id). - Title substring — case-insensitive match against the session title.
A value that isn't latest/costliest is tried as both a UUID prefix and a
title substring (either match keeps the session).
--tools
Lifts the tools.top cap from 7 to all tools (and shows all in human output).
--open
After printing, opens the dashboard to the resolved session — only valid when
exactly one session matched. With 2+ matches it prints a stderr note and
skips opening; with 0 matched sessions it silently does nothing. Either way
it never changes the JSON. In multi-cwd comparison mode
(--cwd a,b) --open is ignored entirely.
See also
docs/data-model.md— the per-entry summary fields (cost,usage,toolCalls,skillCalls,sysHash/toolsHash/coreHash,isSubagent,cwd, …) thatusageaggregates fromindex.ndjson.server/usage.js— the implementation.- README → Usage Analytics CLI — task-oriented command examples.
Changelog
- 2026-09-04 — Added
meta.retentionDays,meta.retentionCutoff, and the conditionalmeta.retentionWarning. The human time-range line now gives the same lower-bound warning when the requested range reaches before retention. A non-numericLOG_RETENTION_DAYSis represented asretentionDays: null. Breaking: multi-cwd comparison output changed from a bare array to{ projects, meta }so the same disclosure reaches that surface — its totals sit behind the same retention window and were previously presented as exact.--lastis now forwarded into the comparison's retention verdict, which previously judged every window as all-time. ALOG_RETENTION_DAYSbeyond the supported window is now treated as retention off; previously such a value fed an out-of-range date into the cutoff and pruned every log file. The cutoff is also now computed in the Taipei calendar rather than the host's, so it no longer depends on where the process runs (unchanged on a Taipei or UTC host; a DST-observing host previously drifted by a day around its transitions). - 2026-06-21 (Claude, Opus 4.8) — Initial schema reference for
ccxray usage --jsonas shipped in PR #94. Documents the single-scope object, multi-cwd comparison shape, and error object, plus filter semantics and per-field precision. Backed by theusage --json shape contracttest that locks the key set and field types of every section.