Architecture

One Go process with four jobs: watch Docker, store what it sees, serve it, and snapshot crashes. There is no external service — state lives in one SQLite file plus small in-memory ring buffers.

overview
Docker Engine  →  CapScope (one Go binary)  →  SQLite (one file)
                    │
                    ├─ collector: follows logs + stats per container
                    ├─ hub: SSE fan-out to the browser
                    ├─ store: WAL mode, batched inserts, retention
                    └─ api: REST + SSE + embedded single-page UI

Components

PackageResponsibility
internal/dockerThin client for the Docker Engine HTTP API over unix:// or tcp://. Decodes multiplexed log framing (8-byte header per frame), the TTY raw stream, stats JSON and events JSON. No Docker SDK dependency.
internal/collectorManager owns one tracker per running container: discovery, log and stats followers, the events loop, snapshot creation and the retention loop.
internal/storeSchema, migrations (PRAGMA user_version), batched inserts, filtered queries, metric bucketing, pruning and the size cap. One writer, a small read-only pool.
internal/hubNon-blocking fan-out of committed log rows to SSE subscribers, with per-subscriber filters and a drop counter.
internal/logprocRedact (secret masking) and Level (level heuristic).
internal/apiJSON endpoints, SSE stream, Basic auth, Markdown export, embedded UI.
internal/alertsRule loading and validation (hot-reloaded JSON), evaluation engine, notification channels.
internal/configEnvironment parsing and validation. Fails fast on a missing password.

Log ingestion

Ensure the tracker

ensure(id) inspects the container, derives the app name and starts a logLoop and a statsLoop.

Follow the stream

GET /containers/{id}/logs?follow=1&timestamps=1. First connect uses tail=INITIAL_TAIL; reconnects use since=lastTS+1ns, so a dropped stream resumes without duplicates.

Process each line

Timestamp parsed, de-duplicated, redacted, level-tagged, pushed into the tracker's ring buffer, then sent on a buffered channel.

Batch and commit

A single writer drains the channel, batching up to 1000 rows or 250 ms into one transaction, then publishes the committed rows — now with ids — to the hub.

Fan out

SSE clients only ever see committed rows, so every row has a stable id and the UI can reconcile a live stream against a history query using id.

Back-pressure. If SQLite stalls, the channel fills and further log lines are dropped (counted, and reported as a periodic log lines dropped (write pressure) warning) so one stuck writer cannot freeze every container's stream. The hub never blocks either: slow browsers lose rows and are told via a dropped event.

Crash snapshot sequence

die / oom / kill
Docker            eventLoop             Manager                     store
  | oom event --->  | setOOM() on tracker, or remember the flag        |
  | kill (sig) -->  | setSignal() on tracker, or remember the flag     |
  | die (exit=137)->| detach tracker from map (synchronous)            |
  |                 | go finishDie():                                  |
  |                 |   tracked: mark exited, wait <=2s for logLoop    |
  |                 |            to drain, cancel stats follower       |
  |                 |   no tracker: rebuild from inspect + SQLite tail |
  |                 |   inspect (OOMKilled, RestartCount, limits)      |
  |                 |   classify -> oom | crash | killed | clean_exit  |
  |                 |   skip when (container, die_ts) already stored   |
  |                 |   count deaths in the restart-loop window        |
  |                 |   build snapshot from ring logs + ring metrics   |
  |                 |   summarize() rule-based text ------------------> INSERT snapshots
  |                 |   save event cursor (die/oom force a write) -----> state

Design points

  • The tracker is detached from the map inside the event goroutine before any async work. With a restart policy Docker reuses the container id, so the next start must create a fresh tracker.
  • Evidence comes from in-memory ring buffers, not SQLite — the database write is batched and can lag the final lines by up to 250 ms.
  • A die with no tracker (CapScope was down, or the container started and died between reconciles) is rebuilt from inspect, with last lines from SQLite when available, otherwise from the tail of Docker's own log file, which still holds a stopped container's final lines.
  • Catch-up after downtime: the newest processed event time is persisted in state. On startup the stream resumes from that cursor (up to 24 h back), so deaths that happened while CapScope was off are replayed. (container_id, die_ts) deduplication makes a replay harmless.
  • Classification: OOM wins, exit 0 is clean_exit, a seen kill/stop signal makes it killed, everything else is crash.
  • Summaries are rule-based, not diagnoses: they state what the data shows and what the exit code usually means (137, 139, 143, 126, 127, 1).
  • The stored inspect is trimmed — environment variables and labels are deliberately left out because they often hold secrets.

App naming

CapRover runs each app as a Swarm service named srv-captain--<app>, with tasks srv-captain--<app>.<slot>.<taskid>. The app name comes from the label com.docker.swarm.service.name and the replica from the task name. Fallbacks: Docker Compose labels, then the container name. Replicas of one app appear together in the UI.

Storage

SQLite in WAL mode, synchronous=NORMAL, busy_timeout=5000. A single writer serializes all writes; a read-only pool of 4 serves the UI, so dashboard queries never wait for ingestion. auto_vacuum=INCREMENTAL is set before the first table exists so retention can return space to the OS.

TableKey columnsNotes
logsid, ts (ns), container_id, container, app, stream, level, lineIndexes on (app,id), (container_id,id), (ts). ~400 bytes per row measured for 130-byte lines.
metricsts (ms), container_id, app, cpu, mem, mem_limit, net, blk, pidsApp charts average per container inside a bucket, then sum across containers.
containersid, name, app, image, replica, state, first/last seenDrives the sidebar and history for removed containers.
snapshotsmetadata + logs_json, metrics_json, inspect_jsonHeavy columns are only read for the detail view; (container_id, die_ts) dedupes replayed deaths.
alertsid, ts (ms), rule, app, kind, messageOne row per firing — doubles as the silence window.
deploysid, ts (ms), app, version, noteRelease markers written by CI or the UI.
statekey, integer valueCheckpoints — currently the Docker event replay cursor.

Measured throughput: about 156,000 rows/second inserted in a store-level micro benchmark (2 vCPUs, batches of 1000, WAL). That excludes stream decoding, redaction and level detection — treat it as an upper bound.

Concurrency model

GoroutineCountLifetime
eventLoop1process
reconcile ticker1process
writer1process
maintenance1process
alerts.Run1process
logLoop + statsLoop2 per running containeruntil the container stops
finishDie1 per deathshort
SSE handler1 per open browser streamuntil disconnect

Security model

ConcernApproach
Access to UI and APIHTTP Basic auth with constant-time comparison, required unless ALLOW_NO_AUTH=true. /healthz is open.
TransportCapScope speaks plain HTTP — put it behind your platform's HTTPS.
Docker accessThe socket is root equivalent. Prefer DOCKER_HOST=tcp:// pointing at a read-only socket proxy exposing only GET /containers/*, /events and /_ping. CapScope only issues GET requests.
Secrets in logsBest-effort masking before storage and before display: JWT, Bearer/Basic, key=value for password/secret/token/api key/authorization, URL credentials, AWS key ids, PEM private keys.
Secrets in snapshotsEnvironment and labels are never stored. Logs pass through the same redaction.
Multi-userNot in the MVP — one shared credential. Per-app permissions and an audit log are on the roadmap.

Failure modes

FailureBehavior
Docker event stream dropsReconnect after 2 s with since=lastEventTime, then a full reconcile. Duplicates are harmless — an already-snapshotted death is skipped by (container_id, die_ts).
Log stream dropsReconnect after 1 s from lastTS+1ns if the container is still running.
Container vanishes without a dieReconcile drops its tracker after 20 s. No snapshot is created.
CapScope restartsFollowers resume from stored timestamps. Deaths during downtime are replayed from the event cursor (gaps up to 24 h).
SQLite write errorLogged, the batch is dropped, following continues.
Disk fullWrites fail and are logged. Lower MAX_DB_MB so the cap triggers before the volume fills.
Browser too slowHub drops rows for that subscriber and emits a dropped event.

Decisions and trade-offs

DecisionWhyCost
Raw Docker HTTP, no SDKFewer dependencies, works against a socket proxy, only four endpoints neededWe maintain the small decoder ourselves
mattn/go-sqlite3 (cgo)Mature and fastNeeds a C toolchain in the build stage; modernc.org/sqlite is a pure-Go swap in one package
SSE instead of WebSocketOne-way data, one header through nginx, automatic reconnectNo client→server channel (not needed yet)
Ring buffer plus SQLiteSnapshots need the final lines even if the batch has not flushed~200 lines and ~60 samples of memory per container
Redact on ingestSecrets never reach disk or the browserOriginal lines are not recoverable; a false positive hides a harmless value
Snapshot kinds default to crash,oomRollouts generate many killed stopsAn external SIGKILL with no preceding kill event is classified as crash
Roadmap: Prometheus /metrics endpoint, agent mode for multi-node clusters, row normalization, per-app permissions, OIDC, and access-log attribution with GeoIP.