decoyrail

Analytics: decoyrail stats

decoyrail status answers one question: what has this month cost so far. decoyrail stats answers the rest: what did my agents spend and do, over which window, broken down by session, model, host, or day, with the security events (denies, tripwires, DLP alerts) given the same prominence as the dollars. It reads only the local audit log. Nothing leaves your machine, and no network access is needed.

decoyrail stats                        # today, broken down by model
decoyrail stats --window week          # this week (Monday start, local time)
decoyrail stats --window month --by session
decoyrail stats --since 2026-06-01 --until 2026-06-30 --by day
decoyrail stats --json                 # machine-readable, schema v1
decoyrail stats --line                 # one line, built for embedding

A typical report:

Window: today (2026-07-10T04 to 2026-07-11T04 UTC)
Requests: 41 (39 allowed, 0 warned, 2 denied: 1 policy, 1 tripwire, 0 dlp, 0 budget)
Security: 1 tripwire hits, 0 DLP alerts
Tokens: in 48.2k  out 12.9k  cache read 1.1M  cache write 22.0k  cached 96%
Spend: $1.8420
Bytes: up 1.2 MB  down 6.4 MB
Latency: avg 830ms  p95 ~2.0s  max 9.4s  (39 measured)

By model:
  claude-sonnet-5     37 req  in     1.1M  out    12.9k  $1.8420  cached 96%
  (no usage data)      4 req  in        0  out        0  $0.0000  [2 alerts]

Zeros are printed on purpose. A window with no tripwires saying "0 tripwire hits" is itself a report; silence would be ambiguous.

Windows and breakdowns

Sessions are decoyrail run invocations. Each one writes a session event labeling itself with the command it launched, so --by session shows claude -p "fix the tests" rather than a bare pid. A long-running decoyrail proxy appears as one session labeled proxy.

Where the numbers come from

Every number derives from the audit log, never from meter.json. The meter zeroes itself at month rollover; the audit log does not, which is what makes "compare this month to last" possible. Spend is the sum of provider-reported token usage priced per model, exactly what metering recorded per request. Requests whose response carried no usage are counted in a visible no_usage_requests bucket and cost $0.00 here; they are never priced by guesswork (the byte-derived estimate exists only in decoyrail status, labeled as an estimate).

Streamed responses report usage after the response finishes, as a companion usage event referencing the original request. Stats correlates the two, so a streamed request counts exactly once in every breakdown.

Latency comes from per-request durations the proxy records. Events written by older Decoyrail versions have no duration; they are shown in the request counts but excluded from the latency figures (the measured count says how many requests the figures cover). The p95 is approximate, computed from a log-scale histogram, and marked with ~.

Repeat queries are fast

The first query after new traffic parses only the audit lines added since the previous query and folds them into ~/.decoyrail/stats-cache.json, an hour-granular aggregate. Repeat queries answer from that cache, so months of history come back in well under a second. The cache is disposable: delete it and the next query rebuilds it from the log.

Integrity

Every ingested line is verified against the hash chain, and the last event is checked against the head anchor. If the chain is broken or the log was truncated, the report still shows what could be read, with the failure flagged at the top of every output mode (the human view, the JSON integrity object, and a prefix on the one-liner). A mid-file edit made after a line was already ingested is caught by the full pass of decoyrail log --verify, which re-derives the chain from the beginning.

Stats output contains only metadata that is already safe in the audit log: hosts, rule names, counts, token totals, fingerprints. Never secret values, prompts, or payloads. One caveat worth knowing: session labels are the command lines you ran, so if you pass a secret as a CLI argument it will show there, exactly as it already would in your shell history.

The one-line mode

decoyrail stats --line emits exactly one line with three fields: today's total tokens, dollars, and alert count, in that order.

1.2M tok  $4.31  3 alerts

The alert count is denies plus tripwire hits plus DLP alerts plus warn events, each counted once. If the audit chain fails verification the line is prefixed with [audit integrity FAILED] . This format is a compatibility promise: poll it from a menu bar app or a statusline every few seconds and nothing about it will change shape within a major version. For anything richer, use --json.

The JSON contract (schema v1)

decoyrail stats --json emits a versioned report. schema increments only on breaking changes. Running the same query twice with no new traffic returns byte-identical output, and the numbers are always identical to the human view of the same window.

{
  "schema": 1,
  "window": {"kind": "today", "from": "2026-07-10T04", "to": "2026-07-11T04"},
  "integrity": {"ok": true, "detail": null},
  "totals": { ... bucket ... },
  "by_session": [{"sid": "…", "label": "claude -p hello", "started": "…", "pid": 812, ... bucket ...}],
  "by_model":  [{"name": "claude-sonnet-5", ... bucket ...}],
  "by_host":   [{"name": "api.anthropic.com", ... bucket ...}],
  "by_day":    [{"name": "2026-07-10", ... bucket ...}]
}

window.from/window.to are half-open UTC hour bounds (YYYY-MM-DDTHH); null means unbounded (--window all). Every bucket, totals and breakdown rows alike, has the same shape:

Field Meaning
requests, allows requests seen, requests forwarded
warns requests forwarded under the warn action: the per-host rows answer "what would break under default deny" (added in a v1-compatible way; absent means an older binary)
denies {total, policy, tripwire, dlp, budget}, denies by reason
tripwires tripwire hits: request-side denies plus response echoes
dlp_alerts DLP warn or mask hits (blocking hits are in denies.dlp)
tokens {input, output, cache_read, cache_write, total}
cache_hit_ratio cache_read / (input + cache_read), null with no context tokens
cost_usd sum of per-request metered cost (subscription traffic is $0)
no_usage_requests allowed requests whose response carried no usage
bytes {up, down} as seen at the proxy
duration_ms {avg, p95, max, measured}, null when nothing was measured

Breakdown rows are sorted by cost (then requests, then name), except by_day, which is chronological. Model rows use the meter's model key, so plan-covered traffic shows as <model> [subscription] and never blends into a pay-per-token row; requests without usage data group under (no usage data).