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 grouped, multi-cwd comparison and error
objects. groupTurns() in server/usage-query.js builds each grouped rollup. 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 top-level shapes (single-scope object, multi-cwd object, grouped 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).
--json emits a single line of compact JSON.stringify output to stdout.
The output shape depends on the arguments:
| Condition | Shape | Exit |
|---|---|---|
--by <dimension> |
Grouped object | 0 |
Default (0 or 1 --cwd, without --by) |
Single-scope object | 0 |
--cwd a,b (2+ values, without --by) |
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.
| 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. Unpriced turns add nothing, so costAgg.unknownCount > 0 makes this a lower bound. |
costAgg |
object | Confidence fold for totalCost, same shape and rules as topSessions[].costAgg below. |
unpricedModels |
string[] | Every model with at least one unpriced turn (no rate, cost.confidence: "unknown"), sorted, not capped. Empty when the total is fully priced. Human output appends (N model(s) unpriced) to the $X+ total. |
retentionDays |
number | null | Active LOG_RETENTION_DAYS value. 0 or below disables retention. null means the setting is not a complete decimal integer (for example, 1e9, 0x10, or 14 days) or is 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. An all-time query without a start bound 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. |
| 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. |
branch |
string | null | Git branch of the session's latest-received turn that recorded one (see branch in data-model.md). null when no turn recorded a branch (outside git, detached HEAD, or entries older than branch recording). |
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. |
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 | null | USD, 2 dp. null when no turn on this model was priced (costStatus: "unknown"); never 0 for an unpriced model. |
costShare |
number | null | model cost / totalCost, 3 dp, range 0–1. The denominator is the priced total, so unpriced turns never enter share math; null when costStatus is "unknown". |
costAgg |
object | Confidence fold for cost, same shape as topSessions[].costAgg. |
costStatus |
string | "priced" (every turn priced), "partial" (some turns unpriced: cost is a lower bound), or "unknown" (no turn priced). Human output prints unknown for an unknown row and renders the others through formatAggCostText. |
| Field | Type | Notes |
|---|---|---|
totalCalls |
number | Total tool invocations across all entries. Per turn, recorded counts (turnToolCalls, else the legacy per-session max of toolCalls) win; a turn with neither (imported transcripts) is counted from its turnToolCallIds map. A null map is unknown and adds nothing; {} is a parsed zero-call turn (ADR 0018). |
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. |
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.
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. |
| 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. |
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. |
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.
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.
These apply to both human and --json output. Grouped reports use the shared
server/usage-query.js read model: responseId copies are merged (ADR 0012),
non-Anthropic sessions use one source (proxy if observed, otherwise import),
and stored stale costs are repriced in memory through the existing pricing
helper. These steps precede attribution and time filters. Grouped reports read the
whole index before selection so even an out-of-window proxy observation fixes
the Codex session's source. Rows with neither a model nor usage are markers and
do not count as turns in grouped reports. Non-grouped reports preserve legacy
rows, including cost-only rows, and apply time/cwd filters before repricing and
responseId deduplication. No index files are modified.
Both accept comma-separated values and repeated flags. Values match recorded fields exactly and case-sensitively; values within a flag are ORed, while agent, branch, cwd, session and time constraints are ANDed.
agentId is the reliable key for per-run attribution. cwd is only a fallback
when one agent owns the worktree: multiple agents sharing a cwd contribute to
its combined total. Missing identities remain visible in nullAgentIdTurns
and the null agent group. Attribution reflects recorded identity; incorrectly
inherited identity or cwd cannot be corrected by a query.
Branch is the latest recorded per-turn branch field, describing what was
recorded at the time. Querying never consults git or the current checkout.
A run that switches branches splits across branch groups. Rows without a
recorded branch count under key: null and nullBranchTurns; no session-wide
branch is substituted.
Absolute timestamps require a date, time with seconds, and timezone, for example
2026-06-01T10:00:00Z or 2026-06-01T18:00:00+08:00. Fractional seconds are
accepted. --since aliases --from; --until aliases --to. Either bound
can be omitted. Selection uses from <= receivedAt < to (start inclusive,
end exclusive); equal bounds describe an empty range. Invalid timestamps,
nonexistent calendar dates, reversed bounds, missing values, and combining
any absolute bound with --last exit 1 with a clear stderr error.
ccxray usage --json --by agent --agent-id herdr:w1:p1,herdr:w1:p2 \
--from 2026-06-01T00:00:00Z --to 2026-06-02T00:00:00Z
ccxray usage --by branch --branch main --branch feature \
--since 2026-06-01T00:00:00Z --until 2026-06-02T00:00:00Z--by agent|branch|cwd|session selects a grouped output in both text and JSON,
including when multiple cwd values are supplied. JSON emits one compact line:
{
"by": "agent",
"groups": [{
"key": "herdr:w1:p1",
"turns": 1,
"tokens": { "input": 10, "cacheRead": 20, "cacheWrite": 30, "output": 40, "reasoning": 5 },
"cost": 0.01,
"costFold": { "count": 1, "fallbackCount": 0, "fallbackCost": 0, "unknownCount": 0 },
"models": { "dummy-model": 1 },
"firstTs": "2026-06-01T10:00:00.000Z",
"lastTs": "2026-06-01T10:00:00.000Z"
}],
"nullAgentIdTurns": 0,
"meta": { "retentionDays": 14, "retentionCutoff": "2026-05-19" }
}key is the recorded agentId, branch, cwd or sessionId string, or JSON null.
Groups sort by key ascending with null last. Counts and tokens are sums;
models maps model IDs to turn counts. firstTs/lastTs are the earliest/latest
selected timestamps as ISO strings, or null when unavailable. Reasoning uses
recorded thinkingTokens, then output token details (reasoning/thinking), then
reasoning_output_tokens; absent evidence contributes zero. Reasoning can be
part of output tokens, so do not add it to output to calculate total tokens.
Grouped costs retain numeric precision and use the existing aggregate ADR 0017
costFold; consumers must render cost with this fold. Unpriced calls contribute
zero cost and increase unknownCount; human output uses formatAggCostText.
nullAgentIdTurns counts selected model calls without agentId for every grouping;
nullBranchTurns is also present when by is branch. The meta retention
fields follow the existing output rules, including conditional
retentionWarning. Null counts reflect the selected scope after all filters.
The number of groups is not capped; the compact default-output size target does
not apply to grouped output.
Keep entries with receivedAt >= now - duration. Forms: 7d, 24h, 30m
(days / hours / minutes). Anything else exits 1 with an error. Applied before
session aliases; grouped reports merge and select sources before filtering.
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.
Without --by, giving 2+ cwd values switches the output to the
multi-cwd comparison object.
Comma-separated or repeated. Resolved after time, agentId, branch and cwd filters, 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).
Lifts the tools.top cap from 7 to all tools (and shows all in human output).
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.
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.
-
2026-11-01 — Added agentId/branch filters, absolute windows and aliases, and opt-in grouped text/JSON output. Default JSON shapes remain unchanged. Grouped reports share merge-before-filter and one-source selection with usage queries and exclude marker rows. Non-grouped reports retain legacy behavior.
-
2026-10-17 — Added
topSessions[].branch(string | null): the git branch of the session's latest-received turn that recorded one. Additive; the multi-cwd and error shapes are unchanged. -
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.
{ "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 */ ] }