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.
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
| Package | Responsibility |
|---|---|
internal/docker | Thin 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/collector | Manager owns one tracker per running container: discovery, log and stats followers, the events loop, snapshot creation and the retention loop. |
internal/store | Schema, migrations (PRAGMA user_version), batched inserts, filtered queries, metric bucketing, pruning and the size cap. One writer, a small read-only pool. |
internal/hub | Non-blocking fan-out of committed log rows to SSE subscribers, with per-subscriber filters and a drop counter. |
internal/logproc | Redact (secret masking) and Level (level heuristic). |
internal/api | JSON endpoints, SSE stream, Basic auth, Markdown export, embedded UI. |
internal/alerts | Rule loading and validation (hot-reloaded JSON), evaluation engine, notification channels. |
internal/config | Environment 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×tamps=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.
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
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
startmust 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
diewith no tracker (CapScope was down, or the container started and died between reconciles) is rebuilt frominspect, 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 itkilled, everything else iscrash. - 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
inspectis 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.
| Table | Key columns | Notes |
|---|---|---|
logs | id, ts (ns), container_id, container, app, stream, level, line | Indexes on (app,id), (container_id,id), (ts). ~400 bytes per row measured for 130-byte lines. |
metrics | ts (ms), container_id, app, cpu, mem, mem_limit, net, blk, pids | App charts average per container inside a bucket, then sum across containers. |
containers | id, name, app, image, replica, state, first/last seen | Drives the sidebar and history for removed containers. |
snapshots | metadata + logs_json, metrics_json, inspect_json | Heavy columns are only read for the detail view; (container_id, die_ts) dedupes replayed deaths. |
alerts | id, ts (ms), rule, app, kind, message | One row per firing — doubles as the silence window. |
deploys | id, ts (ms), app, version, note | Release markers written by CI or the UI. |
state | key, integer value | Checkpoints — 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
| Goroutine | Count | Lifetime |
|---|---|---|
eventLoop | 1 | process |
reconcile ticker | 1 | process |
writer | 1 | process |
maintenance | 1 | process |
alerts.Run | 1 | process |
logLoop + statsLoop | 2 per running container | until the container stops |
finishDie | 1 per death | short |
| SSE handler | 1 per open browser stream | until disconnect |
Security model
| Concern | Approach |
|---|---|
| Access to UI and API | HTTP Basic auth with constant-time comparison, required unless ALLOW_NO_AUTH=true. /healthz is open. |
| Transport | CapScope speaks plain HTTP — put it behind your platform's HTTPS. |
| Docker access | The 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 logs | Best-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 snapshots | Environment and labels are never stored. Logs pass through the same redaction. |
| Multi-user | Not in the MVP — one shared credential. Per-app permissions and an audit log are on the roadmap. |
Failure modes
| Failure | Behavior |
|---|---|
| Docker event stream drops | Reconnect 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 drops | Reconnect after 1 s from lastTS+1ns if the container is still running. |
Container vanishes without a die | Reconcile drops its tracker after 20 s. No snapshot is created. |
| CapScope restarts | Followers resume from stored timestamps. Deaths during downtime are replayed from the event cursor (gaps up to 24 h). |
| SQLite write error | Logged, the batch is dropped, following continues. |
| Disk full | Writes fail and are logged. Lower MAX_DB_MB so the cap triggers before the volume fills. |
| Browser too slow | Hub drops rows for that subscriber and emits a dropped event. |
Decisions and trade-offs
| Decision | Why | Cost |
|---|---|---|
| Raw Docker HTTP, no SDK | Fewer dependencies, works against a socket proxy, only four endpoints needed | We maintain the small decoder ourselves |
mattn/go-sqlite3 (cgo) | Mature and fast | Needs a C toolchain in the build stage; modernc.org/sqlite is a pure-Go swap in one package |
| SSE instead of WebSocket | One-way data, one header through nginx, automatic reconnect | No client→server channel (not needed yet) |
| Ring buffer plus SQLite | Snapshots need the final lines even if the batch has not flushed | ~200 lines and ~60 samples of memory per container |
| Redact on ingest | Secrets never reach disk or the browser | Original lines are not recoverable; a false positive hides a harmless value |
Snapshot kinds default to crash,oom | Rollouts generate many killed stops | An external SIGKILL with no preceding kill event is classified as crash |
/metrics endpoint, agent mode for multi-node clusters, row normalization, per-app permissions, OIDC, and access-log attribution with GeoIP.