The history
Something changed and you want to know how: which rollout replaced that
replica, who deleted that container or image alias, whether anything was
watching when it happened. isb serve keeps a persistent history of
everything that happens to what it runs, including changes made with incus
directly, so the current state can always be traced back.
One timeline, four sources:
| Source | What | Actor |
|---|---|---|
controller |
every event the stack controller emits: deploys, rollouts and each replica they create or retire, health changes, restarts, failures, backups, jobs, certificates, volume snapshots and staged restores (volume.*, about the volume: volumes), workspace changes (workspace.created, workspace.updated, workspace.rebuilt, workspace.deleted, workspace.token_rotated) and reaped sandboxes (sandbox.reaped, with the reason), with their kind (deploy.succeeded, ...) |
isb, or for workspace changes the caller who made them |
incus |
every incus lifecycle event in every project, including changes made outside isb (incus delete, incus image alias delete, the incus UI): instance-created/started/stopped/restarted/updated/deleted, instance-exec, image-deleted, image-alias-deleted, storage-volume-*, network-*, profile-*, project-*, ... |
the incus requestor: the unix user for the local socket, or the TLS/OIDC user with its protocol |
audit |
the audit rows (audit log): tool calls, sign-ins, account and token changes | the isb user, token, local(uid N), webhook:... |
marker |
what isb itself saw: serve.started, serve.stopped, incus.gap (events between two times not observed, and why), history.dropped (events lost to a full queue) |
isb |
What is not seen#
- incus keeps no history of its own. Lifecycle events emitted while no
isb serveis running are not captured. Every such stretch is explicit: at start the daemon records anincus.gapfrom the last row it had (and says whether the daemon stopped cleanly), and when the event stream breaks it records the gap once it reconnects (backoff 1 s to 32 s). - For a record that does not depend on isb running, turn on incus' own
logging to Loki (
incus config set loki.api.url=... loki.types=lifecycle) as belt and braces. - A deployment's build output (level
logevents) is not kept here; each deployment keeps its own log (app_deployment_log). - Routine repeats are folded. isb's own reconciliation execs
systemctl is-active(and similar) in every replica every few seconds, which would be most of the history. Forinstance-exec,instance-file-pushed,instance-file-retrieved,instance-log-retrievedandinstance-metrics-retrieved, the first event per instance, program (or file path) and requestor in an hour is recorded as it happens, and the rest are counted and recorded as one summary row (details.repeats: {count, from, to}) when the hour ends. A different program (someone runningsh) is its own row at once. Everything else is recorded one by one.
Never secrets#
incus contexts can carry configuration. Before a context is stored, keys
named environment.*, environment, env, cloud-init.*,
user.user-data, user.vendor-data, user.isb.create-token, and anything
whose name contains secret, password, token, passphrase or private
are dropped; a command line keeps only the program and the number of
arguments ({"program": "sh", "args": 2}), never the arguments; strings are
cut at 256 characters. Controller messages name things, never values.
Who sees what#
| Caller | History rows (controller, incus, markers) | Audit rows |
|---|---|---|
| platform admin, the unix socket, the host CLI | all, host-level included | all |
| org owner, admin | their orgs' | their orgs' |
| org member, viewer | their orgs' | none |
A row belongs to an org when it happened in the org's incus project
(isb-<org>), or (controller events) to one of its stacks. Host-level rows
(org null) are platform-admin only: images and aliases, storage pools,
networks, profiles and projects, the default project, isb-system, and any
project isb does not manage.
Storage, retention, size#
- The
historytable in<state>/audit.db, next to the audit rows: append only (triggers refuseUPDATE, andDELETEoutside pruning) and hash chained on its own chain, like the audit log.audit_verifyandisb audit verifycheck both chains. - Writes go through a bounded queue (10,000 rows) on their own thread, so the
controller and the event stream never wait on the disk. A full queue drops
rows, counts them, and records a
history.droppedmarker with the count. - Retention: rows older than
--history-retention(default365d) are pruned at start and hourly, and past--history-max-rows(default 5,000,000) the oldest go first. Pruning keeps the chain anchored. - Disk use, measured on a busy test host (39 instances in several projects, other people's sandboxes active, one 2-replica test stack): without folding, isb's own probes alone made about 48 rows a minute (about 70,000 a day for one small stack); with folding, 1.3 rows a minute over an 8-minute quiet stretch, plus one summary row per routine kind, instance and program each hour, so a few thousand rows a day. A row takes about 1.5 KB on disk with its indexes and the WAL, so that is a few MB a day, and the default bound (5,000,000 rows) caps the table at roughly 7 GB.
Queries#
history_query(a tool, so MCP, REST,/orgs/<org>/...): filtersorg,platform(host-level rows only),object(an instance, image, alias, volume, stack, app or service name: substring, or the whole name withexact),kind(glob on the kind or audit action:instance-*,deploy.*,secret_*),source(audit,controller,incus,marker, comma-separated),actor(glob),since/until(unix ms),limit(default 100, at most 1000). Newest first, or oldest first withascending; page withbefore= thenextyou got.correlate: trueaddsinferredto incus instance events: the audit row in the same org within two minutes before whose target names the instance's stack or app ({audit_id, action, actor, seconds_before, why}), labelled as inferred, by time and name.GET /api/v1/history/stream[?org=ORG]: new items as server-sent events (event: history), only what the caller may read; the event id resumes the stream.- The CLI reads
<state>/audit.dbdirectly (as the daemon's user):
isb history NAME NAME's whole timeline, oldest first, with inferred causes
isb history [--object NAME [--exact]] [--org ORG | --platform] [--source incus,audit]
[--kind GLOB] [--actor GLOB] [--since 24h] [--until 1h] [-n 50] [--json]
isb history --export [filters] every match as JSON lines, oldest first
For example, who created and deleted an image alias, and when (markers are part of every object's timeline, so the stretches nobody watched show):
$ isb history --object isbtest-p53-dev-base --exact
TIME SOURCE ORG KIND OBJECT ACTOR LEVEL MESSAGE
2026-10-03 09:38:57.310 incus - image-alias-deleted isbtest-p53-dev-base alice
2026-10-03 09:38:56.248 incus - image-alias-created isbtest-p53-dev-base alice
2026-10-03 09:38:37.541 marker - serve.started isb info isb serve 0.7.0 started
The incus requestor is the unix user behind the socket, so changes isb makes show its own user too (the daemon runs as one); the controller events and the inferred audit row say which were isb's.
The web UI#
History under each org (every member) and in Platform (everything,
host level only, or one org): a source filter (audit log, isb events, incus
changes, gaps and restarts), object, kind and actor filters, time range,
older entries on demand, a live tail, details and the likely cause of an
incus change on a click, and Export JSONL. /orgs/ORG/audit opens it
filtered to the audit log.
Settings#
| Flag | Environment | Default |
|---|---|---|
--history-retention |
ISB_HISTORY_RETENTION |
365d |
--history-max-rows |
ISB_HISTORY_MAX_ROWS |
5000000 |