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. The usage --json shape contract block in test/usage.test.js locks 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 <5KB size ceiling (the <4KB above 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:

ConditionShapeExit
Default (0 or 1 --cwd)Single-scope object0
--cwd a,b (2+ values)Multi-cwd object0
No index / no matching entriesError object1

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

FieldTypeNotes
totalEntriesnumberCount of entries (turns) after all filters.
totalSessionsnumberDistinct sessionId count. Entries with no session id collapse into one "unknown" bucket, which counts here.
totalCostnumberSum of per-turn cost, USD, 2 dp.
retentionDaysnumber | nullActive 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.
retentionCutoffstring | nullTaipei-local YYYY-MM-DD cutoff used by log pruning; null when retention is disabled.
retentionWarningstring (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.fromstring | nullEarliest receivedAt as ISO 8601, or null if no timestamps.
timeRange.tostring | nullLatest receivedAt as ISO 8601, or null.

sessions

FieldTypeNotes
countnumberSame value as meta.totalSessions.
byProviderobjectMap of provider → turn count (e.g. { "anthropic": 42 }). Missing provider → "unknown". Counts turns, not sessions.
subagentRationumbersubagent turns / totalEntries, 3 dp, range 0–1.
turnDistributionobject{ 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.
topSessionsarrayUp 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:

FieldTypeNotes
sessionIdstringFull session id.
turnsnumberTurns in the session.
costnumberUSD, 2 dp.
costAggobjectConfidence 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.
durationMinnumberFirst→last receivedAt span in minutes, 1 dp. 0 if fewer than 2 timestamped turns.
titlestring | nullBest non-continuation title, truncated to 40 chars. null if none.
modelstringThe session's dominant model (most turns), as a single string — not a map. "unknown" if absent.
providerstringProvider of the first turn.

models

Array of up to 10 models, sorted by turns descending.

FieldTypeNotes
modelstringModel id. "unknown" if absent.
turnsnumberTurns on this model.
costnumberUSD, 2 dp.
costSharenumbermodel cost / totalCost, 3 dp, range 0–1.

tools

FieldTypeNotes
totalCallsnumberTotal tool invocations across all entries.
toparray[{ name, count }], sorted by count descending. Capped at 7 by default; --tools lifts the cap to all tools.
failRatenumberentries 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 [].

FieldTypeNotes
namestringSkill name (may be namespaced, e.g. superpowers:brainstorming).
invocationsnumberTotal times invoked.
loadsnumber | nullDistinct sessions that invoked it (a proxy for unique "loads"). null only on the synthetic legacy row.
scopestring | 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" }
}
FieldTypeNotes
changeRatenumberchanged pairs / pairs, 4 dp, range 0–1.
pairsnumberAdjacent turn pairs (within a session) where both turns carry that hash.
labelstringBucketed changeRate: > 0.5every-turn, > 0.1frequent, > 0.01occasional, > 0rare, = 0never.

cache

FieldTypeNotes
hitRatenumbercache-read tokens / all input tokens, 3 dp, range 0–1.
totalInputTokensnumberAll input tokens = input + cache_creation + cache_read, summed. (Not just input_tokens.)
totalOutputTokensnumberSum of output_tokens.
totalCacheReadTokensnumberSum 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.

FieldTypeNotes
gapstringOne of "<30s", "30s-5m", "5-15m", "15-60m", ">60m".
turnsnumberInter-turn gaps measured in this bucket (not sessions).
avgHitRatenumberMean per-turn cache hit rate in the bucket, 3 dp.
medianHitRatenumberMedian 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.

FieldTypeNotes
cwdstringWorking 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.
costnumberThat group's own single-scope meta.totalCost, 2 dp. Not a field of the top-level meta above, which carries only the retention triple.
sessionsnumberThat group's own single-scope meta.totalSessions.
turnsnumberThat group's own single-scope meta.totalEntries.
cacheHitnumberThat 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>" }
errorWhenhint
"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/proj matches /work/proj and /work/proj/sub, but not the sibling /work/proj-other.
  • Anything else → case-insensitive substring against the full cwd. A leading ./ is stripped, so ./foo behaves like foo.

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:

  • Aliaslatest (newest by receivedAt in scope) or costliest (highest summed cost in scope).
  • UUID prefixe.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, …) that usage aggregates from index.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 conditional meta.retentionWarning. The human time-range line now gives the same lower-bound warning when the requested range reaches before retention. A non-numeric LOG_RETENTION_DAYS is represented as retentionDays: 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. --last is now forwarded into the comparison's retention verdict, which previously judged every window as all-time. A LOG_RETENTION_DAYS beyond 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 --json as 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 the usage --json shape contract test that locks the key set and field types of every section.