Skip to content

Operational metrics kept over hours and shown in the console #623

Closed
9 of 9 issues completed
Closed

Description

@V3RON

Request: #612

Problem

The console can only show what happened in the last hour, and only what it saw itself. The daemon keeps no figures: the console builds its charts by counting the events it holds, so a reload starts them from zero. Several figures an operator needs are not sent at all: how long requests wait, why a waiting request is not placed, and why a device was quarantined.

Working the figures out from the event history on every read does not scale. The history can hold about a million events, and one full read takes more than a second. It also keeps only seven days.

Who it is for

Operators of a device cloud, one host or a gateway with workers. They want to know whether they have enough capacity, why agents wait, and why a device was taken out of use. A team lead deciding whether to buy another Mac. Someone tuning capacity, the warm pool or lease times who wants to see the effect.

Outcome

  • The Overview shows how long requests waited (95th percentile) and how many leases were granted, for a chosen range: the last hour, 6 hours, 24 hours or 7 days. The same range drives three charts: leases held, requests in the queue, and device slots in use. A reload or a daemon restart shows the same figures.
  • The Waiting page shows wait p95 for the last hour. Its inspector says, for a waiting request, why it is not placed yet. On a gateway there is one line per worker ("Full: running limit", "Drained", "OS not installed"); on a single host there is one line, the host. simlock list --requests prints the same reason.
  • The Attention inspector says why a device was quarantined: what failed ("Purge failed", "Shutdown failed", "Delete failed", "Stalled while provisioning") and the tool's short error message. It still says so after a restart.
  • simlock stats, GET /v1/stats and a new Usage page in the console show the full figures for a window. These are requests, grants and how each was served, wait, held time and turnaround, provisioning and boot time, rejections by reason, device incidents, and use per platform, per worker and per requester. A CSV export gives one row per request.
  • Figures reach back 90 days by default. The event history itself is kept 7 days, as today.
  • A 7-day figure comes back within half a second, even with 90 days of figures kept.
  • Nothing slows a lease: recording the figures adds about 30 µs per event, written where the event is written today.

How it works for the user

flowchart LR
  O[Operator opens Overview] --> P{Range: 1h / 6h / 24h / 7d}
  P --> C[Tiles and 3 charts for that range]
  C -->|reload or daemon restart| C
  O --> W[Waiting inspector]
  W --> R["One reason per worker:<br/>Full: running limit / Drained / ..."]
  O --> A[Attention inspector]
  A --> Q["Purge failed:<br/>DriverError: simctl erase exited 164"]
  P -->|range older than the kept figures| N["Note: figures cover from &lt;time&gt;"]
Loading

Before:

$ simlock list --requests
Request req_8f2: tok_ci, iPhone 16, queued, waiting 42s

$ simlock stats
error: unknown command 'stats'

After:

$ simlock list --requests
Request req_8f2: tok_ci, iPhone 16, queued, waiting 42s — full: running limit for iOS

$ simlock stats --since 24h
Usage from 2026-10-15T13:00:00.000Z to 2026-10-16T13:00:00.000Z
  Requests:     214 (209 granted, 5 rejected)
  Sources:      warm 160, booted 41, provisioned 8
  Wait:         p50 400ms, p95 38s, max 2m10s (214 samples)
  Slots:        peak 8 of 8, mean 5.2
...

A history that was damaged at start (once):

$ simlock status
...
History: the event history was damaged and set aside at 2026-10-16 09:12; a fresh one was started.

Examples

The reason a waiting request gives, by what stops it:

Situation Today After
Single host, iOS running limit reached nothing Full: running limit for iOS
Single host, request behind another nothing Behind an earlier request
Gateway, worker drained nothing that worker's line: Drained
Gateway, worker without the OS nothing that worker's line: OS not installed
Gateway, worker answered full without a capacity reason nothing Full
Gateway, worker on an older Simlock nothing Protocol mismatch
Request already being placed (starting) nothing no reason; the stage says it

Ranges asked for:

Ask Today After
Console range 1h counted from events the page holds from the daemon
Console range 7d not offered from the daemon
simlock stats --since 30d on a host upgraded yesterday no command figures, with "Figures cover from "
simlock stats --from <100 days ago> --to <95 days ago> no command error: the history does not reach back that far

What could go wrong

  • The figures could undercount after a crash or a full disk. Likely only on a full disk; the status says the history is not being written.
  • A gateway's charts have gaps for the time a worker was disconnected. Expected; a gap shows as no data, not zero.
  • A damaged history file is set aside and figures restart empty. Rare; the status says so once, and the old file is kept.
  • After upgrading, figures start empty: the old event file is not imported.

Words used

  • Event history: the record of every fact Simlock emitted, e.g. lease.granted. Kept 7 days.
  • Figures: numbers worked out from the history, e.g. wait p95 for the last 24 hours. Kept 90 days.
  • p95 (95th percentile): the wait that 95 of 100 requests stayed under, e.g. "p95 38s".
  • Range / window: the span the figures cover, counted back from now, e.g. "last 24 hours".
  • Reason (waiting): the first check that stopped a waiting request at its last placement attempt, e.g. "Full: running limit for iOS".
  • Quarantine: a device taken out of use after cleaning it failed; Simlock retries and gives up after three tries.
  • Set aside: a damaged history file renamed to history.db.damaged-<time> and kept, never deleted.

Non-goals

  • Moving the registry, tokens or worker list into SQLite (Daemon state stores in SQLite (state.db) #624).
  • Importing the old event file on upgrade.
  • Alerting, thresholds, notifications, billing.
  • A Prometheus or OpenMetrics endpoint.
  • Explicit start and end dates in the console; the CLI and the route take them.
  • Measuring what happens inside a device.
  • Recording anything about a person beyond the requester id and token label events already carry.

Completion conditions

  • On the fake driver, after a scripted run, the Overview shows wait p95 and leases granted, and three charts for the picked range (1h, 6h, 24h, 7d). After a reload and after simlock daemon stop and start, the same range shows the same numbers.
  • The Waiting page shows wait p95 for the last hour. For a request waiting on a full single host, its inspector shows one line with the capacity reason. On a gateway with two workers, one drained, it shows two lines, one saying Drained. simlock list --requests prints the same reason.
  • A device quarantined after a failed purge shows Purge failed and the error text, cut to 300 characters, in the Attention inspector, in simlock status --json, and after a daemon restart.
  • simlock stats --since 24h --json, GET /v1/stats?since=24h and the Usage page for 24 hours show the same figures, and those match a count by hand from simlock events --since 24h.
  • simlock stats --csv writes one row per lease request in the window.
  • A fresh install writes history.db and no events.jsonl. simlock events --since 1h works with and without a running daemon.
  • Events older than eventLog.retention are gone after the next prune; figures older than that are still answered until metrics.retention.
  • A history.db corrupted while the daemon is stopped is renamed aside at the next start. Leasing works, and simlock status says the history was set aside.
  • docs/CLI.md, docs/HTTP-API.md, docs/CONSOLE.md, docs/CONFIGURATION.md and docs/EVENTS.md describe the commands, the route, the pages, the keys and the history file.

Open questions

Decisions

Shared values

Value or state Rule Written by Read by Source
history.db <data dir>/history.db, the only event history. events.jsonl is never written or read again; an existing one is left untouched. #629 #630, #631, CLI events ADR 0032 §1
events table One row per envelope, unique by id; a second insert of the same id is ignored. Indexed by timestamp. #629 #630 (rebuild), events.replay ADR 0032 §1, §5
One transaction per event The event row and every projection change for it commit together, synchronously, in the bus subscriber. WAL, synchronous=NORMAL. #629 (writer), #630 (projection step inside it) — ADR 0032 §2
Write failure First failing write logs once (error) and stops all history writes until restart; the daemon keeps running. #629 #629 (status.get) ADR 0032 §2; today src/bus/event-file.ts:134
status.get history { opened: boolean, writing: boolean, setAsideAt?: number, rebuiltAt?: number, rebuildFailed?: true } (epoch ms). Sent whenever known, while starting too. opened false until the file is open, and when it could not be opened. writing false after a write failure or when not opened. setAsideAt set when this start renamed a damaged file. rebuiltAt set when this start rebuilt projections; rebuildFailed when that rebuild failed (the projections are then empty for the window the events cover). #629 (opened, writing, setAsideAt), #630 (rebuiltAt, rebuildFailed) #629 CLI simlock status, #631 (opened false → HISTORY_UNAVAILABLE) this session
Status lines History: the event history is not being written; see daemon.log. / History: the event history was damaged and set aside at <local time>; a fresh one was started. / History: figures were rebuilt from the event history at <local time>. / History: figures could not be rebuilt; see daemon.log. Printed by simlock status only when the field is set. #629 (first two), #630 (last two) — this session
Damaged file PRAGMA quick_check not ok, or open fails → rename to history.db.damaged-<epoch ms> (with its -wal and -shm), never delete, open a fresh file. #629 — ADR 0032 §4
Config keys eventLog.retention (ms, default 7 days) on event rows. eventLog.maxBytes (default 256 MiB, at least 16 MiB) on the stored size of the event rows, oldest first; projection rows do not count. metrics.retention (ms, default the larger of 90 days and eventLog.retention) on projection rows; a value shorter than eventLog.retention fails the load naming the key. A value out of bounds fails the load naming the key. eventLog.rotateBytes is removed: the daemon warns about it as an unknown key, and simlock config set drops it from the file when it writes, saying so once. #629 (eventLog, rotateBytes), #630 (metrics) #629, #630 ADR 0032 §3; src/core/config.ts:756-760, :832; src/cli/index.ts:339-351
Prune At daemon start, after the socket claim, and every hour: event rows by eventLog.retention, then the oldest event rows until their stored size fits maxBytes; projection rows by metrics.retention; then an incremental vacuum and wal_checkpoint(TRUNCATE) give the space back. A lock held by another process waits up to 5 s and is never damage. #629 (events, vacuum), #630 (rows) — ADR 0032 §3, §4
Request row One per lease.requested, and one per lease.rejected with no lease.requested (flag refusedAtAdmission, requestedAt = the rejection's time). Fields: requestId, requester, platform, model?, workerId?, requestedAt, grantedAt?, rejectedAt?, reason?, source?, leaseId?, workerLeaseId?, heldMs?, endedAt?, endReason?, probe, declined? (a worker's row for a gateway dispatch, fleetRequestId set). A probe's lease.declined sets rejectedAt, reason and declined: true. On a gateway, heldMs is measured on the worker's clock (ADR 0021 §5); on a worker it is endedAt - grantedAt. #630 #631, #632 (ledger) ADR 0032 §2; ADR 0016 §2; ADR 0021 §5
Minute row Per minute (start epoch ms, floor) and workerId ("" on a worker for itself, "*" for a gateway's fleet total) and platform (ios, android, or all for the worker's or fleet's total). Values leasesHeld, slotsUsed, slotsMax, ramUsedBytes?, ramLimitBytes?, queueDepth, waiting, each as peak, last and integral (value × ms in the minute), plus knownMs, the ms of the minute with a known value. Queue depth and RAM exist only on all rows. all and * rows are updated on every step, so their peak is the true total peak. A minute row is written only for a minute in which a value changed. A reader carries the last value forward through minutes with no row, up to to. A daemon stop, a daemon start and, on a gateway, worker.disconnected write a gap marker; nothing is carried across one, so the gap is no data. #630 #631 ADR 0032 §2, §6
Device fact row One per device.provisioned (duration), device.ready with bootDuration, device.quarantined, device.recovered, device.quarantine-recovered, device.recovery-failed, device.quarantine-abandoned, device.quarantine-stranded, and lease.declined: at, kind, durationMs?, platform?, workerId?, deviceId?. Kept for metrics.retention. #630 #631 ADR 0032 §2
Device event platform platform: "ios" | "android" added to the payload of lease.granted, device.ready, device.quarantined, device.recovered, device.quarantine-recovered, device.recovery-failed, device.quarantine-abandoned and device.quarantine-stranded (additive). A device fact row takes its platform from its event; an event from before this field has none. #635 (device.quarantined), #630 (the others) #630 this session; src/bus/index.ts:115-176
Projection version An integer per projection in the database; an upgrade that changes the rows ships a SQL migration. A failed migration, or a stored version newer than the code's, stops the daemon from starting with an error naming the projection and the version. A failed projection check at the right version rebuilds at start from events, in batches that let other work run; rows older than the oldest event are kept. #630 #630 ADR 0032 §4
usage.get Role admin, input { from, to, includeLedger? } epoch ms, from < to, to - from ≤ 90 days; from is floored to the bucket width, to is not rounded. Output window, coversFrom, partial, bucketMs, totals, platforms, workers, requesters, series[], and requests[] only when includeLedger. A series point is { at (bucket end), leasesHeld, slotsUsed, slotsMax, ramUsedBytes?, queueDepth, waiting } from the all rows (* on a gateway), each the peak in its bucket, null where there is no data. Means are time-weighted from the integrals. Percentiles by nearest rank; null over an empty set. Reads run on a worker thread with a read-only connection; one read per window in flight, a second asker for that window waits for it. A 7-day answer over 90 days of rows returns within 500 ms and never holds up a lease. #631 (#632 adds includeLedger, requests[]) #632, #633, #634 ADR 0016 §5, §8; ADR 0032 §5
HISTORY_NOT_KEPT domain error, CLI exit 12, HTTP 422, details { oldestTs }; when the window ends before the oldest projection row. #631 #632 ADR 0016 §5
history.usage The handle #630 hands out and #631 reads, on the read thread: oldestTs(), requestRows({from,to}), minuteBuckets({from,to,bucketMs,workerId,platform}) (peak = max of minute peaks, integral = sum, knownMs = sum, carrying across empty minutes), deviceFacts({from,to}). #630 #631, #632 ADR 0032 §5
HISTORY_UNAVAILABLE domain error, CLI exit 1, HTTP 503, as DOCTOR_UNAVAILABLE; usage.get answers it when history.db could not be opened. #631 #632 src/contract/errors.ts:338-344
Bucket width Smallest of 1 min, 5 min, 15 min, 1 h, 6 h, 1 day that keeps ≤ 200 points. #631 #633 (axis labels) ADR 0016 §8
GET /v1/stats ?since=<duration> or ?from=<ISO>&to=<ISO> (to defaults to now); neither = last 24 h; &ledger=1 adds requests[], any other ledger value is 400. Operator token; agent token 403. The ledger holds at most 100,000 rows, the newest kept, with ledgerTruncated: true when cut; simlock stats --csv says so on stderr. CSV cells starting with =, +, - or @ get a leading '. #632 #633, #634 this session
Console ranges 1h, 6h, 24h, 7d, in the URL as ?range=; default 1h. Fetched as the console fetches every route (ADR 0013 §3: on events and every second, coalesced). The Waiting page's wait p95 is always the last hour. #633 #634 ADR 0013 §3; this session
Waiting reason (worker) waiting[].reason on a queued entry, never on starting: behind-earlier-request, device-limit, ram-budget, global-running-limit, platform-running-limit, waiting-for-device, no-free-physical. On a gateway, a worker's own local waiting entries keep their reason in the gateway's list. From the request's latest plan; a failed eviction keeps the capacity reason that asked for it; a match being cleaned gives the capacity reason; a boot claim already taken gives waiting-for-device. #636 #636 (CLI, console), #637 this session
Waiting reason (gateway) waiting[].workers[]: { workerId, reason, capacityReason? }, one per worker the gateway holds a view of (disconnected and incompatible included). reason: disconnected, incompatible, not-ready, unhealthy, drained, busy, no-driver, unknown-model, runtime-missing, capability-missing, full, no-free-physical, not-chosen. The first failing check in stage order wins; drained comes before a remembered refusal. From the request's latest walk. capacityReason only with full; a value outside the four reads as plain full. #637 #637 (CLI, console) this session
Reason text behind-earlier-request "Behind an earlier request"; device-limit "Full: device limit"; ram-budget "Full: RAM budget"; global-running-limit "Full: running limit"; platform-running-limit "Full: running limit for <iOS|Android>"; waiting-for-device "A matching device is starting"; no-free-physical "Every matching device is leased or absent"; disconnected "Not connected"; incompatible "Protocol mismatch"; not-ready "Not ready yet"; unhealthy "Not running"; drained "Drained"; busy "Busy with its own queue"; no-driver "No <iOS|Android> support"; unknown-model "Model not offered"; runtime-missing "OS not installed"; capability-missing "Missing capability"; full "Full", or "Full: " + the capacity reason's text after "Full: "; not-chosen "Not chosen this time". The CLI prints a worker reason after —, and each gateway line indented as <worker label or id>: <text>, with only the text's first letter lower-cased. #636 (worker), #637 (gateway) console, CLI this session
NO_CAPACITY detail A worker's NO_CAPACITY error carries details.capacityReason, one of the four capacity reasons, when it has one; HTTP's 503 body carries it too. A contract shape change: the PR bumps the protocol by one. #636 #637 this session; src/contract/AGENTS.md:59
PROVISION_FAILED Error code, kind domain, HTTP 500, CLI exit code 1. A worker rejects a request whose new device fails to provision twice with it, the driver's error as its message; a probe that fails twice answers it too. A gateway walks on to the next worker, and rejects with it and the last worker's message when every worker that could take the request answered it. #636 #637, CLI, HTTP maintainer, 2026-10-18; src/contract/errors.ts:226-386 (decided by the maintainer, 2026-10-18)
Quarantine cause Device record and status.get device: quarantineCause { kind: "purge-failed" | "shutdown-failed" | "delete-failed" | "stalled", stalledState?: "provisioning" | "reclaiming", message? }. Written once, on entry; retries leave it. message is the driver's error summary, cleaned like capabilityReason, cut to 300 characters with a final … inside them when cut, left out when empty; stalled has none. status.get and list.get leave message out for an agent-role caller; operators and the host CLI see it. Dropped when the device leaves quarantine. device.quarantined gains the same three fields. #635 #635 (console, CLI) this session; src/core/stable-error.ts:10, src/core/doctor.ts:78

Tasks

flowchart LR
  T7["#635 quarantine cause"] --> T8["#636 why waiting: worker"] --> T9["#637 why waiting: gateway"] --> T1["#629 history.db"]
  T1 --> T2["#630 projections"] --> T3["#631 usage.get + stats"] --> T4["#632 /v1/stats + CSV"]
  T4 --> T5["#633 Overview range + charts"] --> T6["#634 Usage page"]
Loading

Seven tasks change contract shapes and each bumps the protocol, so they run one after another (#635 → #636 → #637 → #629 → #630 → #631 → #632). The small ones go first, by the maintainer's choice. #633 and #634 change only the console.

Completion conditions and the task that delivers each:

Open work in the same area:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    on Oct 16, 2026
  2. V3RON commented on Oct 16, 2026

    @V3RON
    ContributorAuthor

    Spec updated: all business sections written; Decisions (ADR 0032, PR #626), Shared values and Tasks (#629–#637) added. Supersedes #329.

    Written by an agent.

  3. V3RON commented on Oct 16, 2026

    @V3RON
    ContributorAuthor

    Spec updated after the first spec check: Shared values (history status, size cap, minute rows, projection version, reads, quarantine message, wait reasons, platform on events, history.usage) and the task order; every task body fixed for its blocking findings.

    Written by an agent.

  4. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Oct 16, 2026
  5. V3RON commented on Oct 18, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: none. The feature's stack merges together at the end: #626 (ADR) and #690 (#635) are in it and ready.
    Parked: #636 — a blocking finding (F1, the queue wake with two twice-failed waiters) is still open after the review cap; it needs a maintainer decision.
    Waiting: #637 — on #636; #629 — on #637; #630 — on #629; #631–#634 — each on the task before it.
    Decided: #636 — does the wake after a second provisioning failure replan that request? → no (the PR comment on #692 says so; still pending the maintainer).
    Asleep: 2 min

    Written by an agent.

  6. V3RON commented on Oct 18, 2026

    @V3RON
    ContributorAuthor

    Delivery run (2)

    Merged: none. The stack merges at the end: #626, #690 (#635) and #692 (#636) are in it and ready.
    Parked: #637 — a src finding confirmed by the fix-check (a disconnected worker counts as able to take the request); PR #693 is still a draft.
    Waiting: #629 — on #637; #630–#634 — each on the task before it.
    Decided: #636 — second provisioning failure → PROVISION_FAILED, exit 1 (by the maintainer); #637 — the gateway walks on after PROVISION_FAILED only before progress (in delivery, pending the maintainer).

    Written by an agent.

  7. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: none. The stack merges at the end: #626, #690 (#635), #692 (#636), #693 (#637) and #694 (#629) are ready.
    Parked: #630 — the session budget ran out mid-green; the handoff on #630 lists the five test fixes.
    Waiting: #631–#634 — each on the task before it.
    Decided: #636 — second provisioning failure → PROVISION_FAILED, exit 1 (by the maintainer); #637 — the gateway walks on after PROVISION_FAILED only before progress (pending the maintainer); #630 — where a red test disagrees with the spec, the spec wins.
    Asleep: 269 min

    Written by an agent.

  8. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: none
    Parked: #630 — the replay treats a falling seq as a new run, which wipes state when worker clocks are skewed; how to find a run boundary needs a decision (PR #695)
    Waiting: #631 — on #630, #632 — on #631, #633 — on #632, #634 — on #633

    Written by an agent.

  9. github-actions commented on Oct 19, 2026

    @github-actions

    All 9 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  10. github-actions commented on Oct 19, 2026

    @github-actions

    All 9 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  11. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: #626 (ADR 0032 and #629–#637, as one PR after a stack sync made the stack's PRs merge into each other)
    Parked: none
    Waiting: none
    Decided:

    Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:plannedSplit into tasks. Never picked up itself.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions