Repository navigation
GET /v1/stats and the simlock stats --csv ledger #632
Description
Part of #623.
Scope
After this PR, GET /v1/stats answers the same figures as simlock stats --json for the same window, with an operator token, on a single host and on a gateway. simlock stats --csv writes one row per lease request in the window, a file a spreadsheet opens. Both read the ledger from the request rows #630 writes, through usage.get (#631).
In short
A dashboard or script fetches GET /v1/stats?since=24h and gets the figures as JSON. &ledger=1 adds requests[], one entry per request. An operator runs simlock stats --since 7d --csv > week.csv and opens it in a spreadsheet.
A ledger entry is one request's life: when it was asked, granted or refused, and ended, and what it waited and held, e.g. req_8f2, tok_ci, ci-runner, ios, iPhone 16, …, granted, false. The ledger holds at most 100,000 entries, the newest; a longer window is cut and says so.
flowchart LR
H["GET /v1/stats (new)"] --> W[[usage window rules<br/>contract, #631]]
CLI["simlock stats --csv (new flag)"] --> W
W --> U["usage.get + includeLedger (changed)"]
U --> R[(request rows, #630)]
CLI --> CSV[["CSV writer (new)"]]
Session, before and after:
$ curl -s -H "Authorization: Bearer $OP" 'http://127.0.0.1:4700/v1/stats?since=1h'
404 # today: no route
$ curl -s -H "Authorization: Bearer $OP" 'http://127.0.0.1:4700/v1/stats?since=1h'
{"window":{"from":…,"to":…},"coversFrom":…,"partial":false,"bucketMs":60000,"totals":{…},…}
$ simlock stats --since 1h --csv
requestId,requester,label,platform,model,workerId,source,requestedAt,grantedAt,rejectedAt,endedAt,waitMs,heldMs,turnaroundMs,outcome,probe,reason,endReason
req_8f2,tok_ci,ci-runner,ios,iPhone 16,,warm,2026-10-16T12:01:02.000Z,2026-10-16T12:01:02.400Z,,2026-10-16T12:09:30.000Z,400,507600,508000,granted,false,,explicit
$ simlock stats --since 90d --csv > q.csv # a window with more than 100,000 requests
{"notice":"The ledger holds the newest 100000 requests of the window; ask for a shorter window for the rest."}
Inputs:
| Ask | Today | After |
|---|---|---|
GET /v1/stats |
404 | 200, the last 24 hours |
GET /v1/stats?since=1h |
404 | 200, the last hour, no requests |
GET /v1/stats?since=1h&ledger=1 |
404 | 200, with requests[] |
GET /v1/stats?from=2026-10-15T00:00Z&to=2026-10-16T00:00Z |
404 | 200, that window |
GET /v1/stats?from=2026-10-15T00:00Z |
404 | 200, from then to now |
GET /v1/stats?since=1h&from=2026-10-15T00:00Z |
404 | 400 BAD_REQUEST stats takes since or from, not both |
GET /v1/stats?from=2026-10-16T00:00Z&to=2026-10-15T00:00Z |
404 | 400 BAD_REQUEST from must be earlier than to, or than now when to is left out |
GET /v1/stats?to=2026-10-16T00:00Z |
404 | 400 BAD_REQUEST to needs from |
GET /v1/stats?since=fortnight |
404 | 400 BAD_REQUEST Invalid duration: fortnight |
GET /v1/stats?from=1 |
404 | 400 BAD_REQUEST Invalid time: 1 (use ISO 8601 with Z or an offset) |
GET /v1/stats?from=2026 |
404 | 400, the same message for 2026 |
GET /v1/stats?from=10/15/2026 |
404 | 400, the same message for 10/15/2026 |
GET /v1/stats?from=2026-10-15T00:00 (no zone) |
404 | 400, the same message |
GET /v1/stats?from=2026-10-15T00:00+02:00 (+ unencoded, read as a space) |
404 | 400, the same message for 2026-10-15T00:00 02:00 |
GET /v1/stats?from=2026-10-15T00:00%2B02:00 |
404 | 200, from 2026-10-14T22:00Z to now |
GET /v1/stats?ledger=yes |
404 | 400 BAD_REQUEST ledger must be 1 |
GET /v1/stats with an agent token |
404 | 403 FORBIDDEN |
| window ending before the oldest row | 404 | 422 HISTORY_NOT_KEPT, body carries oldestTs |
history.db could not be opened |
404 | 503 HISTORY_UNAVAILABLE |
simlock stats --csv --json |
usage error (no stats) |
usage error stats takes --csv or --json, not both, exit 2 |
a window with 100,001 requests, ledger=1 or --csv |
404 / usage error | the newest 100,000, ledgerTruncated: true; the CLI prints the notice above on stderr |
The flag names in a message follow the surface: --since on the CLI, since on the route; the rule behind both is one function.
If this goes wrong, the route and the CLI give different numbers for one window, or the CSV has a row count that does not match simlock events.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | GET /v1/events reads ?since with the contract's duration parser and dispatches in process |
src/http/app.ts:679-688, :838-842; src/contract/duration.ts:15 |
| F2 | A bad query parameter is badRequest, 400 BAD_REQUEST |
src/http/app.ts:840; docs/HTTP-API.md Errors table, 400 row |
| F3 | An admin operation dispatched from a route answers an agent token 403 from the shared dispatcher, with no check in the route | src/http/app.ts:623-628 (/v1/tokens) |
| F4 | mapError puts a code's details in the body when CODES_WITH_DECLARED_DETAILS has the code; HISTORY_NOT_KEPT is one |
src/http/errors.ts:164; task/347:src/contract/errors.ts:211 |
| F5 | HISTORY_NOT_KEPT is HTTP 422; HISTORY_UNAVAILABLE is HTTP 503 |
task/347:src/contract/errors.ts:400; feature #623 Shared values, "HISTORY_UNAVAILABLE" |
| F6 | Every /v1 route but healthz passes requireAuth; routes register in both modes |
src/http/app.ts:196, :227; src/http/AGENTS.md "How a request runs" 3 |
| F7 | docs/HTTP-API.md lists operator routes under "Operator routes" |
docs/HTTP-API.md:1469 |
| F8 | A requester id is any string; nothing bounds its characters | src/contract/operations.ts:150 |
| F9 | A token label is any string | src/contract/operations.ts:684 |
| F10 | The CLI prints JSON results through writeResult |
src/cli/index.ts:2008 |
| F11 | #348's ledger fields and order, includeLedger default false, and the route rules (to defaults to now, neither form = last 24 h) |
gh issue view 348, "Modules touched" |
| F12 | #631 puts the window rules in src/contract/usage-window.ts and runStats in the CLI; #631's history.usage.requestRows returns every request row of the window by requestedAt, then requestId |
#631 "Modules touched", "Surface and extension points" |
| F13 | The CLI writes a notice as one JSON line { notice } on stderr |
src/cli/index.ts:182 |
| F14 | usageWindow(ask, now) returns a window or a refusal naming parameter and problem; times must match strict ISO 8601 with Z or an offset |
#631 "Surface and extension points" |
| F15 | lease.declined carries requestId and reason; #630's request row for a probe takes rejectedAt and reason from it, and a worker emits no lease.rejected for gateway traffic |
src/bus/index.ts:92-98; ADR 0021 Consequences; feature #623 Shared values, "Request row" |
| F16 | lease.released's reason is explicit, killed or device-lost |
src/bus/index.ts:44 |
| F17 | The gateway's handler answers usage.get with fleet set, stripping its own gw: prefix before a label lookup |
#631 "How rows become figures", Requesters |
Modules touched
Searches: /v1/stats, includeLedger, text/csv (0 hits on main); usage.get (#631's files); badRequest in src/http/app.ts.
src/contract/schemas.ts,operations.ts—usage.getinput gainsincludeLedger?: boolean; output gainsrequests?: LedgerEntry[].protocol.ts+1.src/contract/usage-window.ts(usage.getandsimlock statsfrom the projections #631) — called with the query; the route words its refusal (F14).src/core/usage/— ledger entries from request rows, usingusage.getandsimlock statsfrom the projections #631's wait, held and turnaround functions.src/http/app.ts— the route.src/daemon/dispatcher.ts,src/gateway/dispatcher.ts— passincludeLedgerto the reader; on a gateway the ledger's labels come through the same prefix strip (F17).src/cli/index.ts(--csv),src/cli/csv.ts(new, the RFC 4180 writer).- Docs:
docs/HTTP-API.md(route under "Operator routes", 400, 422 and 503 rows of Errors),docs/CLI.md(--csv),docs/CLIENT.md(includeLedger). Manuals: contract (protocol), http. - Tests:
src/contract/operations.test.ts,src/core/usage/*.test.ts,src/core/usage/usage-read.timing.test.ts(usage.getandsimlock statsfrom the projections #631),src/gateway/dispatcher.test.ts,src/http/app.test.ts,src/cli/csv.test.ts,src/cli/index.test.ts,e2e/http-api.test.ts,e2e/usage-stats.test.ts.
flowchart LR
http[http/app*] --> win[contract/usage-window*]
cli[cli*] --> win
cli --> csv[cli/csv* new]
http --> disp[daemon or gateway dispatcher]
cli --> client[simlock-client] --> disp
disp --> ur[core/usage*]
ur --> rows[#630 request rows]
sequenceDiagram
participant C as HTTP client
participant A as http/app
participant D as dispatcher (in process)
C->>A: GET /v1/stats?since=1h&ledger=1
A->>A: window rules (400 on a bad parameter)
A->>D: usage.get { from, to, includeLedger: true }
D->>D: role (403 for agent), parse (400)
alt window ends before oldest row
D-->>A: HISTORY_NOT_KEPT { oldestTs }
A-->>C: 422 { error: { code, message, oldestTs } }
else
D-->>A: output with requests[]
A-->>C: 200 output
end
Contract and event changes
This PR bumps the protocol by one.
usage.getinput gainsincludeLedger: boolean, optional, defaultfalse. A non-boolean isBAD_REQUEST(feature Operational metrics kept over hours and shown in the console #623 Shared values, "usage.get").usage.getoutput gainsrequests[]andledgerTruncated?: true, present only whenincludeLedgeristrue. One entry per request row in the window, probes and refused-at-admission rows included, byrequestedAt, thenrequestId:
{ requestId, requester, label?, platform, model?, workerId?, source?, requestedAt, grantedAt?, rejectedAt?, endedAt?, waitMs?, heldMs?, turnaroundMs?, outcome: "granted" | "rejected" | "declined" | "open", probe: boolean, reason?, endReason? }(F11, withrejectedAt,probeanddeclinedadded).outcome:grantedwithgrantedAtbyto;declinedfor a probe withrejectedAtbyto, the worker'slease.declined(F15);rejectedfor any other row withrejectedAtbyto; elseopen.- At most 100,000 entries: when the window has more, the newest 100,000 are kept and
ledgerTruncatedistrue; otherwise it is absent. waitMs,heldMsandturnaroundMsareusage.getandsimlock statsfrom the projections #631's samples: absent whereusage.getandsimlock statsfrom the projections #631 gives none (a probe's and a refused-at-admission row's wait).- The same window rule as
usage.getandsimlock statsfrom the projections #631's figures: a grant, rejection or end aftertois unseen.- A grant after
to: nograntedAt,sourceorworkerId; the entry isopen. - A rejection after
to: norejectedAtorreason;open. - An end after
to: noendedAt,endReason,heldMsorturnaroundMs; stillgranted.
- A grant after
labelfrom the token store, asusage.getandsimlock statsfrom the projections #631's requesters.
- New route
GET /v1/stats(feature Operational metrics kept over hours and shown in the console #623 Shared values, "GET /v1/stats"):since: a duration the contract's parser takes (F1), else 400Invalid duration: <value>.from,to: strict ISO 8601 withZor an offset (F14), else 400Invalid time: <value> (use ISO 8601 with Z or an offset). A+must be sent as%2B; an unencoded one arrives as a space and is refused.- The other refusals, worded for the route:
stats takes since or from, not both,to needs from,from must be earlier than to, or than now when to is left out. ledger: absent or1; any other value 400ledger must be 1.fromwithouttoruns to now.- The body is
usage.get's output, unwrapped. Errors take their status from the error table (F4, F5).
- The dispatcher parses the output, ledger included, on the main thread;
usage.getandsimlock statsfrom the projections #631's timing test bounds a 100,000-entry answer at 2.5 s (Tests; 1 s in the first spec, measured 1.44 s on the CI runner and about 0.6 s locally — decided in delivery, pending the maintainer). simlock stats --csv: asks withincludeLedger: true, writes the ledger to stdout as RFC 4180 CSV: one header row of the field names above in that order, one row per entry, CRLF line ends, timestamps as ISO 8601 UTC, durations as whole milliseconds (Math.round, no decimal point),probeastrue/false, an empty cell for an absent value. A cell starting with=,+,-or@gets a leading', since requester ids and labels are free text (F8, F9). Then a cell holding a comma, a quote, a CR or an LF is quoted, its quotes doubled. WhenledgerTruncated, the CLI writes the noticeThe ledger holds the newest 100000 requests of the window; ask for a shorter window for the rest.on stderr (F13) and exits 0.- No event changes.
Other code on the same state
Searches: request rows (#630, read only here); usage.get (#631's handlers, reader). One reader: the route and the CLI call the same usage.get; nothing here writes. #631's read (#631 "Other code on the same state") covers #630's writes and prunes landing beside a ledger read: the ledger comes from the same read transaction on the read thread as the figures.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
GET /v1/stats → usage.get |
ok | 200, the output | none new |
| bad parameter | 400 BAD_REQUEST naming it, no dispatch |
— | |
| agent token | 403 FORBIDDEN |
— | |
HISTORY_NOT_KEPT |
422, body carries oldestTs |
— | |
| daemon still starting | parks in the dispatcher as every route (src/daemon/AGENTS.md "Dispatch" 5) |
as today | |
HISTORY_UNAVAILABLE |
503, from the error table | — | |
| read throws | 500 INTERNAL, logged (#631 F15; no new test) |
— | |
simlock stats --csv |
ok | CSV on stdout, exit 0 | — |
| any error | the error line on stderr, nothing on stdout, exit per table (Tests) | — | |
| more than 100,000 requests | the newest 100,000, ledgerTruncated: true, notice on stderr |
— |
Nothing is written: a restart mid-call leaves nothing. Two callers for one window share #631's one read in flight.
Rules in play
- Architecture rule 10: "Never implement the same decision twice": the window rules are
usage.getandsimlock statsfrom the projections #631'susage-window.ts, called by the CLI and the route; wait, held and turnaround areusage.getandsimlock statsfrom the projections #631's functions, used by figures and ledger. - Architecture rule 8: the CLI formats the CSV; the daemon builds the entries.
- Safety rule 10: "Input that does not match something known is refused":
since,from,toandledgerare parsed and bounded before dispatch; a time without a zone, or in any form but ISO 8601, is refused; an unknownledgervalue is refused. - Documentation rules 2 and 3:
docs/HTTP-API.mdnames no ADR; error bodies name no repo path. - Testing rule 2: each 400 test starts from an accepted query, then changes one parameter.
Tests
Seam: e2e/http-api.test.ts for the route over a real daemon; the route's query branches in src/http/app.test.ts, extending "GET /v1/events replays via events.replay, translating ?since to an absolute sinceTs"; src/cli/index.test.ts "CLI: stats" (#631). Ledger entries at #631's seam, the usage.get handler over a real history.db.
- usage.get with
includeLedgerlists one entry per request in the window, byrequestedAtthenrequestId, with the timestamps andwaitMs,heldMsandturnaroundMsof a granted and released request - usage.get's ledger marks a request still waiting at
toasopen, one granted aftertoasopenwith nograntedAt,sourceorworkerId, a lease open attoasgrantedwith noendedAt,endReason,heldMsorturnaroundMs, a rejection asrejectedwith itsrejectedAtand reason, and a declined probe on a worker asdeclined - usage.get's ledger agrees with its figures: entries
grantedequaltotals.granted, and a request'swaitMsis a sample intotals.wait - usage.get leaves
requestsout whenincludeLedgeris absent orfalse, and refuses a non-booleanincludeLedgerasBAD_REQUEST - usage.get's ledger carries the token label of a known requester and none for an unknown one, and lists a probe (
probe: true) and a refused-at-admission rejection as entries - usage.get's ledger over 100,001 requests holds the newest 100,000 with
ledgerTruncated: true, and over exactly 100,000 holds them all with noledgerTruncated - simlock stats --csv writes the truncation notice on stderr and exits 0 when
ledgerTruncated, and writes no notice otherwise - simlock stats --csv on an error (
HISTORY_NOT_KEPT) writes the error line on stderr, nothing on stdout, and exits 12 - the CSV writer quotes a field holding a comma, a quote, a CR or an LF, doubles its quotes, ends rows with CRLF, writes an empty cell for an absent value, and puts
'before a cell starting with=,+,-or@(each of the four) and not before one starting otherwise - simlock stats --csv prints the header row and one row per ledger entry with times as ISO 8601, durations as whole milliseconds (
400, not400.0),probeastrueorfalse, and asksusage.getwithincludeLedger: true - simlock stats refuses
--csvwith--jsonas a usage error, exit 2 - GET /v1/stats dispatches
usage.getforsinceback from now,fromtoto,fromto now, and the last 24 hours with no parameter - GET /v1/stats answers 400 with the Inputs table's message for
sincewithfrom,towithoutfrom,fromnot beforeto, a bad duration, the times1,2026,10/15/2026, no zone and an unencoded+, andledgerother than1, each after the same query without the fault answered 200, and answers 200 for%2B02:00 - GET /v1/stats passes
includeLedger: trueonly forledger=1 - GET /v1/stats answers HISTORY_NOT_KEPT with status 422 and
oldestTsin the body, and HISTORY_UNAVAILABLE with 503 - the gateway handler with
includeLedgerlists the fleet's request rows as entries, labelled after stripping its owngw:prefix, and the worker handler passesincludeLedgerthe same way - (timing) a 100,000-entry ledger answer over
usage.getandsimlock statsfrom the projections #631's seeded rows returns through the dispatcher, output parse included, within 2.5 s - (e2e) GET /v1/stats with an operator token returns the same body as
simlock stats --jsonfor a fixedfrom/to, withoutrequests, and&ledger=1adds them, on a single host and on a gateway with one worker; an agent token gets 403 - (e2e)
simlock stats --csv --from <before the run> --to <after it>after a scripted run with analready-leasedrefusal has one data row perlease.requestedin that window ofsimlock events, plus one for the refusal
Done when
- After a scripted run against the fake driver,
GET /v1/stats?from=<before the run>&to=<after it>with an operator token returns the same figures assimlock stats --from <same> --to <same> --json, on a single host and on a gateway. simlock stats --csv --from <before the run> --to <after it> > run.csvwrites one header row and one row per request row; a spreadsheet opens it with the columns apart.- An agent token gets 403; a window older than the kept figures gets 422 with
oldestTsin the body. docs/HTTP-API.mddescribes the route under "Operator routes" and its 400, 422 and 503 errors;docs/CLI.mddescribes--csv;docs/CLIENT.mddescribesincludeLedger.
Out of scope
- The console's Usage page and its CSV button (Console Usage page #634).
- A Prometheus or OpenMetrics endpoint.
Depends on
Written by an agent.
Activity
refactor(usage): split the ledger entry by what it shows (#632)
test(cli): statsRun's fake connection resolves the admin credential l…
test(usage,cli): a rejection after to is open, a fractional duration …
fix(usage): an open ledger entry shows no workerId; tests for sort or…
test(usage): the 100,000-entry ledger answer is bounded at 2.5 s, as …
feat(history): history.db, usage projections, simlock stats, GET /v1/…